diff --git a/mullvad-api/src/availability.rs b/mullvad-api/src/availability.rs index 0857a7812b59..9d5e135c1553 100644 --- a/mullvad-api/src/availability.rs +++ b/mullvad-api/src/availability.rs @@ -72,7 +72,7 @@ impl ApiAvailability { /// starting it if it's not currently running. pub fn reset_inactivity_timer(&self) { let mut inner = self.acquire(); - log::debug!("Restarting API inactivity check"); + log::trace!("Restarting API inactivity check"); inner.stop_inactivity_timer(); let availability_handle = self.clone(); inner.inactivity_timer = Some(tokio::spawn(async move { @@ -252,7 +252,7 @@ impl ApiAvailabilityState { } fn stop_inactivity_timer(&mut self) { - log::debug!("Stopping API inactivity check"); + log::trace!("Stopping API inactivity check"); if let Some(timer) = self.inactivity_timer.take() { timer.abort(); } diff --git a/mullvad-cli/src/cmds/relay_constraints.rs b/mullvad-cli/src/cmds/relay_constraints.rs index 97555997fcd1..72b3bdb88ec1 100644 --- a/mullvad-cli/src/cmds/relay_constraints.rs +++ b/mullvad-cli/src/cmds/relay_constraints.rs @@ -27,7 +27,6 @@ impl From for Constraint { (country, Some(city), Some(hostname)) => { GeographicLocationConstraint::Hostname(country, city, hostname) } - _ => unreachable!("invalid location arguments"), }) } diff --git a/mullvad-types/src/relay_constraints.rs b/mullvad-types/src/relay_constraints.rs index 4be7e25a4bac..e6eaa7ca55c0 100644 --- a/mullvad-types/src/relay_constraints.rs +++ b/mullvad-types/src/relay_constraints.rs @@ -174,6 +174,12 @@ pub enum GeographicLocationConstraint { Hostname(CountryCode, CityCode, Hostname), } +#[derive(thiserror::Error, Debug)] +#[error("Failed to parse {input} into a geographic location constraint")] +pub struct ParseGeoLocationError { + input: String, +} + impl GeographicLocationConstraint { /// Create a new [`GeographicLocationConstraint`] given a country. pub fn country(country: impl Into) -> Self { @@ -227,6 +233,27 @@ impl Match for GeographicLocationConstraint { } } +impl FromStr for GeographicLocationConstraint { + type Err = ParseGeoLocationError; + + // TODO: Implement for country and city as well? + fn from_str(input: &str) -> Result { + // A host name, such as "se-got-wg-101" maps to + // Country: se + // City: got + // hostname: se-got-wg-101 + let x = input.split("-").collect::>(); + match x[..] { + [country] => Ok(GeographicLocationConstraint::country(country)), + [country, city] => Ok(GeographicLocationConstraint::city(country, city)), + [country, city, ..] => Ok(GeographicLocationConstraint::hostname(country, city, input)), + _ => Err(ParseGeoLocationError { + input: input.to_string(), + }), + } + } +} + /// Limits the set of servers to choose based on ownership. #[derive(Debug, Copy, Clone, Eq, PartialEq, Deserialize, Serialize)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] @@ -677,3 +704,29 @@ impl RelayOverride { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_hostname() { + // Parse a country + assert_eq!( + "se".parse::().unwrap(), + GeographicLocationConstraint::country("se") + ); + // Parse a city + assert_eq!( + "se-got".parse::().unwrap(), + GeographicLocationConstraint::city("se", "got") + ); + // Parse a hostname + assert_eq!( + "se-got-wg-101" + .parse::() + .unwrap(), + GeographicLocationConstraint::hostname("se", "got", "se-got-wg-101") + ); + } +} diff --git a/test/Cargo.lock b/test/Cargo.lock index a4e2ee9df70a..064ae149ad1f 100644 --- a/test/Cargo.lock +++ b/test/Cargo.lock @@ -3597,6 +3597,7 @@ dependencies = [ "dirs", "env_logger", "futures", + "glob", "hyper-util", "inventory", "ipnetwork", diff --git a/test/scripts/test-utils.sh b/test/scripts/test-utils.sh index 319c64602050..be826a6ef053 100755 --- a/test/scripts/test-utils.sh +++ b/test/scripts/test-utils.sh @@ -12,9 +12,9 @@ function get_test_utls_dir { local script_path="${BASH_SOURCE[0]}" local script_dir if [[ -n "$script_path" ]]; then - script_dir="$(cd "$(dirname "$script_path")" > /dev/null && pwd)" + script_dir="$(cd "$(dirname "$script_path")" >/dev/null && pwd)" else - script_dir="$(cd "$(dirname "$0")" > /dev/null && pwd)" + script_dir="$(cd "$(dirname "$0")" >/dev/null && pwd)" fi echo "$script_dir" } @@ -54,7 +54,7 @@ export CURRENT_VERSION export LATEST_STABLE_RELEASE function print_available_releases { - for release in $(jq -r '.[].tag_name'<<<"$RELEASES"); do + for release in $(jq -r '.[].tag_name' <<<"$RELEASES"); do echo "$release" done } @@ -73,7 +73,7 @@ function get_package_dir { exit 1 fi - mkdir -p "$package_dir" || exit 1 + mkdir -p "$package_dir" || exit 1 # Clean up old packages find "$package_dir" -type f -mtime +5 -delete || true @@ -89,7 +89,7 @@ function nice_time { result=$? fi s=$SECONDS - echo "\"$*\" completed in $((s/60))m:$((s%60))s" + echo "\"$*\" completed in $((s / 60))m:$((s % 60))s" return $result } # Matches $1 with a build version string and sets the following exported variables: @@ -122,22 +122,22 @@ function get_app_filename { version="${BUILD_VERSION}${COMMIT_HASH}${TAG:-}" fi case $os in - debian*|ubuntu*) - echo "MullvadVPN-${version}_amd64.deb" - ;; - fedora*) - echo "MullvadVPN-${version}_x86_64.rpm" - ;; - windows*) - echo "MullvadVPN-${version}.exe" - ;; - macos*) - echo "MullvadVPN-${version}.pkg" - ;; - *) - echo "Unsupported target: $os" 1>&2 - return 1 - ;; + debian* | ubuntu*) + echo "MullvadVPN-${version}_amd64.deb" + ;; + fedora*) + echo "MullvadVPN-${version}_x86_64.rpm" + ;; + windows*) + echo "MullvadVPN-${version}.exe" + ;; + macos*) + echo "MullvadVPN-${version}.pkg" + ;; + *) + echo "Unsupported target: $os" 1>&2 + return 1 + ;; esac } @@ -177,19 +177,19 @@ function get_e2e_filename { version="${BUILD_VERSION}${COMMIT_HASH}" fi case $os in - debian*|ubuntu*|fedora*) - echo "app-e2e-tests-${version}-x86_64-unknown-linux-gnu" - ;; - windows*) - echo "app-e2e-tests-${version}-x86_64-pc-windows-msvc.exe" - ;; - macos*) - echo "app-e2e-tests-${version}-aarch64-apple-darwin" - ;; - *) - echo "Unsupported target: $os" 1>&2 - return 1 - ;; + debian* | ubuntu* | fedora*) + echo "app-e2e-tests-${version}-x86_64-unknown-linux-gnu" + ;; + windows*) + echo "app-e2e-tests-${version}-x86_64-pc-windows-msvc.exe" + ;; + macos*) + echo "app-e2e-tests-${version}-aarch64-apple-darwin" + ;; + *) + echo "Unsupported target: $os" 1>&2 + return 1 + ;; esac } @@ -282,38 +282,38 @@ function run_tests_for_os { test_dir=$(get_test_utls_dir)/.. read -ra test_filters_arg <<<"${TEST_FILTERS:-}" # Split the string by words into an array pushd "$test_dir" - if [ -n "${TEST_DIST_DIR+x}" ]; then - if [ ! -x "${TEST_DIST_DIR%/}/test-manager" ]; then - executable_not_found_in_dist_error test-manager - fi - test_manager="${TEST_DIST_DIR%/}/test-manager" - runner_dir_flag=("--runner-dir" "$TEST_DIST_DIR") - else - test_manager="cargo run --bin test-manager" - runner_dir_flag=() + if [ -n "${TEST_DIST_DIR+x}" ]; then + if [ ! -x "${TEST_DIST_DIR%/}/test-manager" ]; then + executable_not_found_in_dist_error test-manager fi + test_manager="${TEST_DIST_DIR%/}/test-manager" + runner_dir_flag=("--runner-dir" "$TEST_DIST_DIR") + else + test_manager="cargo run --bin test-manager" + runner_dir_flag=() + fi - if [ -n "${MULLVAD_HOST+x}" ]; then - mullvad_host_arg=("--mullvad-host" "$MULLVAD_HOST") - else - mullvad_host_arg=() - fi + if [ -n "${MULLVAD_HOST+x}" ]; then + mullvad_host_arg=("--mullvad-host" "$MULLVAD_HOST") + else + mullvad_host_arg=() + fi - if ! RUST_LOG_STYLE=always $test_manager run-tests \ - --account "${ACCOUNT_TOKEN:?Error: ACCOUNT_TOKEN not set}" \ - --app-package "${APP_PACKAGE:?Error: APP_PACKAGE not set}" \ - "${upgrade_package_arg[@]}" \ - "${test_report_arg[@]}" \ - --package-dir "${package_dir}" \ - --vm "$vm" \ - --openvpn-certificate "${OPENVPN_CERTIFICATE:-"assets/openvpn.ca.crt"}" \ - "${mullvad_host_arg[@]}" \ - "${test_filters_arg[@]}" \ - "${runner_dir_flag[@]}" \ - 2>&1 | sed -r "s/${ACCOUNT_TOKEN}/\{ACCOUNT_TOKEN\}/g"; then - echo "Test run failed" - exit 1 - fi + if ! RUST_LOG_STYLE=always $test_manager run-tests \ + --account "${ACCOUNT_TOKEN:?Error: ACCOUNT_TOKEN not set}" \ + --app-package "${APP_PACKAGE:?Error: APP_PACKAGE not set}" \ + "${upgrade_package_arg[@]}" \ + "${test_report_arg[@]}" \ + --package-dir "${package_dir}" \ + --vm "$vm" \ + --openvpn-certificate "${OPENVPN_CERTIFICATE:-"assets/openvpn.ca.crt"}" \ + "${mullvad_host_arg[@]}" \ + "${test_filters_arg[@]}" \ + "${runner_dir_flag[@]}" \ + 2>&1 | sed -r "s/${ACCOUNT_TOKEN}/\{ACCOUNT_TOKEN\}/g"; then + echo "Test run failed" + exit 1 + fi popd } @@ -335,10 +335,10 @@ function build_current_version { if [ ! -f "$app_package" ]; then pushd "$app_dir" - if [[ $(git diff --quiet) ]]; then - echo "WARNING: the app repository contains uncommitted changes, this script will only rebuild the app package when the git hash changes" - fi - ./build.sh + if [[ $(git diff --quiet) ]]; then + echo "WARNING: the app repository contains uncommitted changes, this script will only rebuild the app package when the git hash changes" + fi + ./build.sh popd echo "Moving '$(realpath "$app_dir/dist/$app_filename")' to '$(realpath "$app_package")'" mv -n "$app_dir"/dist/"$app_filename" "$app_package" @@ -348,7 +348,7 @@ function build_current_version { if [ ! -f "$gui_test_bin" ]; then pushd "$app_dir"/gui - npm run build-test-executable + npm run build-test-executable popd echo "Moving '$(realpath "$app_dir/dist/$gui_test_filename")' to '$(realpath "$gui_test_bin")'" mv -n "$app_dir"/dist/"$gui_test_filename" "$gui_test_bin" diff --git a/test/test-by-version.sh b/test/test-by-version.sh index e9de281badf7..f409a214cc9d 100755 --- a/test/test-by-version.sh +++ b/test/test-by-version.sh @@ -7,7 +7,7 @@ usage() { echo echo "Required environment variables:" echo " - ACCOUNT_TOKEN: Valid MullvadVPN account number" - echo " - TEST_OS: Name of the VM configuration to use. List available configurations with 'cargo run --bin test-manager list'" + echo " - TEST_OS: Name of the VM configuration to use. List available configurations with 'cargo run --bin test-manager config vm list'" echo "Optional environment variables:" echo " - APP_VERSION: The version of the app to test (defaults to the latest stable release)" echo " - APP_PACKAGE_TO_UPGRADE_FROM: The package version to upgrade from (defaults to none)" @@ -18,13 +18,13 @@ usage() { echo " - TEST_REPORT : path to save the test results in a structured format" } -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" # shellcheck source=test/scripts/test-utils.sh source "scripts/test-utils.sh" -if [[ ( "$*" == "--help") || "$*" == "-h" ]]; then +if [[ ("$*" == "--help") || "$*" == "-h" ]]; then usage exit 0 fi diff --git a/test/test-manager/Cargo.toml b/test/test-manager/Cargo.toml index 2671ea454a4d..3310ab770fdf 100644 --- a/test/test-manager/Cargo.toml +++ b/test/test-manager/Cargo.toml @@ -32,6 +32,7 @@ async-trait = { workspace = true } uuid = "1.3" dirs = "5.0.1" scopeguard = "1.2" +glob = "0.3" serde = { workspace = true } serde_json = { workspace = true } diff --git a/test/test-manager/docs/config.md b/test/test-manager/docs/config.md index 8c4db7fce45c..09d4f110cec5 100644 --- a/test/test-manager/docs/config.md +++ b/test/test-manager/docs/config.md @@ -2,39 +2,68 @@ This document outlines the format of the configuration used by `test-manager` to perform end-to-end tests in virtualized environments. -# Format +## Format + +The configuration is a JSON document with three values: -The configuration is a JSON document with two values: ```json { "mullvad_host": , - "vms": + "vms": , + "test_locations": [ {"test_name": ["relay"] }, .. ], } ``` The configurable values are prone to change, and for the time being it is probably a good idea to get acquainted with the [Rust struct called "Config"](../src/config.rs) from which the configuration is serialized. -To get started, `test-manager` provides the `test-manager set` command to add and edit VM configurations. +To get started, `test-manager` provides the `test-manager config vm set` command to add and edit VM configurations. It is also recommended to view the [example section](#Examples) further down this document. -# Location +## Location The configuration is assumed to exist in `$XDG_CONFIG_HOME/mullvad-test/config.json` (most likely `$HOME/.config/mullvad-test/config.json`) on Linux and `$HOME/Library/Application Support/mullvad-test/config.json` on macOS. -# Examples +## Per-test relay selection + +It is possible to configure which relay(s) should be selected on a test-per-test basis by providing the `test_locations` +configuration option. If no explicit configuration is given, no assumption will be made from within the tests themselves. + +The format is a list of maps with a single key-value pair, where the key is a [glob pattern]() +that will be matched against the test name, and the value is a list of locations to use for the matching tests. +The name of the locations are the same as for the `mullvad relay set location` CLI-command. + +### Example + +```json +{ + // other fields + "test_locations": [ + { "*daita*": ["se-got-wg-001", "se-got-wg-002"] }, + { "*": ["se"] } + ] +} +``` + +The above example will set the locations for the test `test_daita` to a custom list +containing `se-got-wg-001` and `se-got-wg-002`. The `*` is a wildcard that will match +any test name. The configuration is read from top-to-bottom, and the first match will be used. + +## Example configurations -## Minimal +### Minimal The minimal valid configuration does not contain any virtual machines + ```json { - "mullvad_host": "stagemole.eu", - "vms": { } + "mullvad_host": "stagemole.eu", + "vms": {} } ``` -## Complete +### Complete A configuration containing one Debian 12 VM and one Windows 11 VM + ```json { "mullvad_host": "stagemole.eu", @@ -68,5 +97,6 @@ A configuration containing one Debian 12 VM and one Windows 11 VM "tpm": false } } + } } ``` diff --git a/test/test-manager/src/config/error.rs b/test/test-manager/src/config/error.rs new file mode 100644 index 000000000000..17ad599da9dd --- /dev/null +++ b/test/test-manager/src/config/error.rs @@ -0,0 +1,15 @@ +use std::io; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Could not find config dir")] + FindConfigDir, + #[error("Could not create config dir")] + CreateConfigDir(#[source] io::Error), + #[error("Failed to read config")] + Read(#[source] io::Error), + #[error("Failed to parse config")] + InvalidConfig(#[from] serde_json::Error), + #[error("Failed to write config")] + Write(#[source] io::Error), +} diff --git a/test/test-manager/src/config/io.rs b/test/test-manager/src/config/io.rs new file mode 100644 index 000000000000..1aaefde71b23 --- /dev/null +++ b/test/test-manager/src/config/io.rs @@ -0,0 +1,80 @@ +//! See [ConfigFile]. + +use std::io; +use std::ops::Deref; +use std::path::{Path, PathBuf}; + +use super::{Config, Error}; + +/// On-disk representation of [Config]. +pub struct ConfigFile { + path: PathBuf, + config: Config, +} + +impl ConfigFile { + /// Make config changes and save them to disk + pub async fn edit(&mut self, edit: impl FnOnce(&mut Config)) -> Result<(), Error> { + Self::ensure_config_dir().await?; + edit(&mut self.config); + self.config_save().await + } + + /// Make config changes and save them to disk + pub async fn load_or_default() -> Result { + let path = Self::get_config_path()?; + let config = Self::config_load_or_default(&path).await?; + let config_file = Self { path, config }; + Ok(config_file) + } + + async fn config_load_or_default>(path: P) -> Result { + Self::config_load(path).await.or_else(|error| match error { + Error::Read(ref io_err) if io_err.kind() == io::ErrorKind::NotFound => { + log::trace!("Failed to read config file"); + Ok(Config::default()) + } + error => Err(error), + }) + } + + async fn config_load>(path: P) -> Result { + let data = tokio::fs::read(path).await.map_err(Error::Read)?; + serde_json::from_slice(&data).map_err(Error::InvalidConfig) + } + + async fn config_save(&self) -> Result<(), Error> { + let data = serde_json::to_vec_pretty(&self.config).unwrap(); + tokio::fs::write(&self.path, &data) + .await + .map_err(Error::Write) + } + + /// Get configuration file path + pub fn get_config_path() -> Result { + Ok(Self::get_config_dir()?.join("config.json")) + } + + /// Get configuration file directory + fn get_config_dir() -> Result { + let dir = dirs::config_dir() + .ok_or(Error::FindConfigDir)? + .join("mullvad-test"); + Ok(dir) + } + + /// Create configuration file directory if it does not exist + async fn ensure_config_dir() -> Result<(), Error> { + tokio::fs::create_dir_all(Self::get_config_dir()?) + .await + .map_err(Error::CreateConfigDir) + } +} + +impl Deref for ConfigFile { + type Target = Config; + + fn deref(&self) -> &Self::Target { + &self.config + } +} diff --git a/test/test-manager/src/config/manifest.rs b/test/test-manager/src/config/manifest.rs new file mode 100644 index 000000000000..fdf24a8d5a8e --- /dev/null +++ b/test/test-manager/src/config/manifest.rs @@ -0,0 +1,110 @@ +//! Config definition, see [`Config`]. + +mod test_locations; +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use test_locations::TestLocationList; + +use super::VmConfig; +use crate::tests::config::DEFAULT_MULLVAD_HOST; + +/// Global configuration for the `test-manager`. +/// +/// Can be modified using either the setting file, see +/// [`crate::config::io::ConfigFile::get_config_path`] or +/// the `test-manager config` CLI subcommand. +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct Config { + #[serde(skip)] + pub runtime_opts: RuntimeOptions, + pub vms: BTreeMap, + pub mullvad_host: Option, + #[serde(default)] + pub test_locations: TestLocationList, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct RuntimeOptions { + pub display: Display, + pub keep_changes: bool, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub enum Display { + #[default] + None, + Local, + Vnc, +} + +impl Config { + pub fn get_vm(&self, name: &str) -> Option<&VmConfig> { + self.vms.get(name) + } + + /// Get the Mullvad host to use. + /// + /// Defaults to [`DEFAULT_MULLVAD_HOST`] if the host was not provided in the [`ConfigFile`]. + pub fn get_host(&self) -> String { + self.mullvad_host.clone().unwrap_or_else(|| { + log::debug!("No Mullvad host has been set explicitly. Falling back to default host"); + DEFAULT_MULLVAD_HOST.to_owned() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_test_location_empty() { + let config = r#" + { + "vms": {}, + "mullvad_host": "mullvad.net" + }"#; + + let config: Config = serde_json::from_str(config).unwrap(); + assert!(config.test_locations.0.is_empty()); + } + + #[test] + fn parse_test_location_not_empty() { + let config = r#" + { + "vms": {}, + "mullvad_host": "mullvad.net", + "test_locations": [ + { "*daita": [ "se-got-wg-001", "se-got-wg-002" ] }, + { "*": [ "se" ] } + ] + }"#; + + let config: Config = serde_json::from_str(config).unwrap(); + assert!(config + .test_locations + .lookup("test_daita") + .unwrap() + .contains(&"se-got-wg-002".to_string())); + assert!(!config.test_locations.0.is_empty()); + } + + #[test] + fn parse_multiple_keys_in_map_should_fail() { + let config = r#" + { + "vms": {}, + "mullvad_host": "mullvad.net", + "test_locations": [ + { + "*daita": [ "se-got-wg-001", "se-got-wg-002" ], + "*test": ["se"] + }, + ] + }"#; + + let _err = serde_json::from_str::(config).unwrap_err(); + } +} diff --git a/test/test-manager/src/config/manifest/test_locations.rs b/test/test-manager/src/config/manifest/test_locations.rs new file mode 100644 index 000000000000..febf9ed46004 --- /dev/null +++ b/test/test-manager/src/config/manifest/test_locations.rs @@ -0,0 +1,105 @@ +use serde::{ + de::{Deserialize, Deserializer, Error, MapAccess, Visitor}, + ser::{Serialize, SerializeMap}, + Deserialize as DeserDerive, Serialize as SerDerive, +}; +use std::fmt; + +#[derive(Clone, Default)] +pub struct TestLocation(glob::Pattern, Vec); + +impl fmt::Debug for TestLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {:?}", self.0, &self.1) + } +} + +/// Relay/location overrides for tests. +/// +/// # Deserializing with `serde-json` +/// +/// The format is a list of maps with a single key-value +/// pair, where the key is a glob pattern that will be matched against the test name, and the +/// value is a list of locations to use for that test. The first match will be used. +/// +/// Example: +/// ```json +/// { +/// // other fields +/// "test_locations": [ +/// { "*daita*": [ "se-got-wg-001", "se-got-wg-002" ] }, +/// { "*": [ "se" ] } +/// ] +/// } +/// ``` +/// +/// The above example will set the locations for the test `test_daita` to a custom list +/// containing `se-got-wg-001` and `se-got-wg-002`. The `*` is a wildcard that will match +/// any test name. The order of the list is important, as the first match will be used. +#[derive(Debug, DeserDerive, SerDerive, Clone, Default)] +pub struct TestLocationList(pub Vec); + +impl TestLocationList { + pub fn lookup(&self, test: &str) -> Option<&Vec> { + self.0 + .iter() + .find(|TestLocation(test_glob, _)| test_glob.matches(test)) + .map(|TestLocation(_, locations)| locations) + } +} + +struct TestLocationVisitor; + +impl<'de> Visitor<'de> for TestLocationVisitor { + // The type that our Visitor is going to produce. + type Value = TestLocation; + + // Format a message stating what data this Visitor expects to receive. + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("A list of maps") + } + + fn visit_map(self, mut access: M) -> Result + where + M: MapAccess<'de>, + { + let (key, value) = access + .next_entry::>()? + .ok_or(M::Error::custom( + "Test location map should contain exactly one key-value pair, but it was empty", + ))?; + let glob = glob::Pattern::new(&key).map_err(|err| { + M::Error::custom(format!( + "Cannot compile glob pattern from: {key} error: {err:?}" + )) + })?; + + if let Some((key, value)) = access.next_entry::>()? { + return Err(M::Error::custom(format!( + "Test location map should contain exactly one key-value pair, but found another key: '{key}' and value: '{value:?}'" + ))); + } + + Ok(TestLocation(glob, value)) + } +} + +impl<'de> Deserialize<'de> for TestLocation { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_map(TestLocationVisitor) + } +} + +impl Serialize for TestLocation { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry(self.0.as_str(), &self.1)?; + map.end() + } +} diff --git a/test/test-manager/src/config/mod.rs b/test/test-manager/src/config/mod.rs new file mode 100644 index 000000000000..4b510b87cc7f --- /dev/null +++ b/test/test-manager/src/config/mod.rs @@ -0,0 +1,11 @@ +//! Test manager configuration. + +mod error; +mod io; +mod manifest; +mod vm; + +use error::Error; +pub use io::ConfigFile; +pub use manifest::{Config, Display}; +pub use vm::{Architecture, OsType, PackageType, Provisioner, VmConfig, VmType}; diff --git a/test/test-manager/src/config.rs b/test/test-manager/src/config/vm.rs similarity index 55% rename from test/test-manager/src/config.rs rename to test/test-manager/src/config/vm.rs index 95a13c48a62a..911d2fcf6406 100644 --- a/test/test-manager/src/config.rs +++ b/test/test-manager/src/config/vm.rs @@ -1,141 +1,9 @@ -//! Test manager configuration. +//! Virtual machine configuration. -use serde::{Deserialize, Serialize}; -use std::{ - collections::BTreeMap, - env, io, - ops::Deref, - path::{Path, PathBuf}, -}; - -use crate::tests::config::DEFAULT_MULLVAD_HOST; - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("Could not find config dir")] - FindConfigDir, - #[error("Could not create config dir")] - CreateConfigDir(#[source] io::Error), - #[error("Failed to read config")] - Read(#[source] io::Error), - #[error("Failed to parse config")] - InvalidConfig(#[from] serde_json::Error), - #[error("Failed to write config")] - Write(#[source] io::Error), -} - -#[derive(Default, Serialize, Deserialize, Clone)] -pub struct Config { - #[serde(skip)] - pub runtime_opts: RuntimeOptions, - pub vms: BTreeMap, - pub mullvad_host: Option, -} - -#[derive(Default, Serialize, Deserialize, Clone)] -pub struct RuntimeOptions { - pub display: Display, - pub keep_changes: bool, -} - -#[derive(Default, Serialize, Deserialize, Clone)] -pub enum Display { - #[default] - None, - Local, - Vnc, -} - -impl Config { - async fn load_or_default>(path: P) -> Result { - Self::load(path).await.or_else(|error| match error { - Error::Read(ref io_err) if io_err.kind() == io::ErrorKind::NotFound => { - Ok(Self::default()) - } - error => Err(error), - }) - } - - async fn load>(path: P) -> Result { - let data = tokio::fs::read(path).await.map_err(Error::Read)?; - serde_json::from_slice(&data).map_err(Error::InvalidConfig) - } - - async fn save>(&self, path: P) -> Result<(), Error> { - let data = serde_json::to_vec_pretty(self).unwrap(); - tokio::fs::write(path, &data).await.map_err(Error::Write) - } - - pub fn get_vm(&self, name: &str) -> Option<&VmConfig> { - self.vms.get(name) - } - - /// Get the Mullvad host to use. - /// - /// Defaults to [`DEFAULT_MULLVAD_HOST`] if the host was not provided in the [`ConfigFile`]. - pub fn get_host(&self) -> String { - self.mullvad_host.clone().unwrap_or_else(|| { - log::debug!("No Mullvad host has been set explicitly. Falling back to default host"); - DEFAULT_MULLVAD_HOST.to_owned() - }) - } -} - -pub struct ConfigFile { - path: PathBuf, - config: Config, -} - -impl ConfigFile { - /// Make config changes and save them to disk - pub async fn load_or_default() -> Result { - Self::load_or_default_inner(Self::get_config_path()?).await - } - - /// Get configuration file path - fn get_config_path() -> Result { - Ok(Self::get_config_dir()?.join("config.json")) - } - - /// Get configuration file directory - fn get_config_dir() -> Result { - let dir = dirs::config_dir() - .ok_or(Error::FindConfigDir)? - .join("mullvad-test"); - Ok(dir) - } - - /// Create configuration file directory if it does not exist - async fn ensure_config_dir() -> Result<(), Error> { - tokio::fs::create_dir_all(Self::get_config_dir()?) - .await - .map_err(Error::CreateConfigDir) - } +use std::env; +use std::path::{Path, PathBuf}; - /// Make config changes and save them to disk - async fn load_or_default_inner>(path: P) -> Result { - Ok(Self { - path: path.as_ref().to_path_buf(), - config: Config::load_or_default(path).await?, - }) - } - - /// Make config changes and save them to disk - pub async fn edit(&mut self, edit: impl FnOnce(&mut Config)) -> Result<(), Error> { - Self::ensure_config_dir().await?; - - edit(&mut self.config); - self.config.save(&self.path).await - } -} - -impl Deref for ConfigFile { - type Target = Config; - - fn deref(&self) -> &Self::Target { - &self.config - } -} +use serde::{Deserialize, Serialize}; #[derive(clap::Args, Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] diff --git a/test/test-manager/src/main.rs b/test/test-manager/src/main.rs index 95274e9a7a8e..694af65dc3a2 100644 --- a/test/test-manager/src/main.rs +++ b/test/test-manager/src/main.rs @@ -13,8 +13,9 @@ mod vm; use std::net::IpAddr; use std::{net::SocketAddr, path::PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{Context, Ok, Result}; use clap::{builder::PossibleValuesParser, Parser}; +use config::ConfigFile; use package::TargetInfo; use tests::{config::TEST_CONFIG, get_filtered_tests}; use vm::provision; @@ -31,28 +32,13 @@ struct Args { #[derive(clap::Subcommand, Debug)] enum Commands { - /// Create or edit a VM config - Set { - /// Name of the VM config - vm: String, - - /// VM config - #[clap(flatten)] - config: config::VmConfig, - }, - - /// Remove specified VM config - Remove { - /// Name of the VM config, run `test-manager list` to see available configs - vm: String, - }, - - /// List available VM configurations - List, + /// Manage configuration for tests and VMs + #[clap(subcommand)] + Config(ConfigArg), /// Spawn a runner instance without running any tests RunVm { - /// Name of the VM config, run `test-manager list` to see available configs + /// Name of the VM config, run `test-manager config vm list` to see configured VMs vm: String, /// Run VNC server on a specified port @@ -69,7 +55,7 @@ enum Commands { /// Spawn a runner instance and run tests RunTests { - /// Name of the VM config, run `test-manager list` to see available configs + /// Name of the VM config, run `test-manager config vm list` to see configured VMs #[arg(long)] vm: String, @@ -161,6 +147,39 @@ enum Commands { }, } +#[derive(clap::Subcommand, Debug)] +enum ConfigArg { + /// Print the current config + Get, + /// Print the path to the current config file + Which, + /// Manage VM-specific setting + #[clap(subcommand)] + Vm(VmConfig), +} + +#[derive(clap::Subcommand, Debug)] +enum VmConfig { + /// Create or edit a VM config + Set { + /// Name of the VM config + vm: String, + + /// VM config + #[clap(flatten)] + config: config::VmConfig, + }, + + /// Remove specified VM config + Remove { + /// Name of the VM config, run `test-manager config vm list` to see configured VMs + vm: String, + }, + + /// List available VM configurations + List, +} + #[cfg(target_os = "linux")] impl Args { fn get_vnc_port(&self) -> Option { @@ -184,33 +203,50 @@ async fn main() -> Result<()> { .await .context("Failed to load config")?; match args.cmd { - Commands::Set { - vm, - config: vm_config, - } => vm::set_config(&mut config, &vm, vm_config) - .await - .context("Failed to edit or create VM config"), - Commands::Remove { vm } => { - if config.get_vm(&vm).is_none() { - println!("No such configuration"); - return Ok(()); + Commands::Config(config_subcommand) => match config_subcommand { + ConfigArg::Get => { + println!("{:#?}", *config); + Ok(()) } - config - .edit(|config| { - config.vms.remove_entry(&vm); - }) - .await - .context("Failed to remove config entry")?; - println!("Removed configuration \"{vm}\""); - Ok(()) - } - Commands::List => { - println!("Available configurations:"); - for (vm, config) in config.vms.iter() { - println!("{vm}: {config:#?}"); + ConfigArg::Which => { + println!( + "{}", + ConfigFile::get_config_path() + .expect("Get config path") + .display() + ); + Ok(()) } - Ok(()) - } + ConfigArg::Vm(vm_config) => match vm_config { + VmConfig::Set { + vm, + config: vm_config, + } => vm::set_config(&mut config, &vm, vm_config) + .await + .context("Failed to edit or create VM config"), + VmConfig::Remove { vm } => { + if config.get_vm(&vm).is_none() { + println!("No such configuration"); + return Ok(()); + } + config + .edit(|config| { + config.vms.remove_entry(&vm); + }) + .await + .context("Failed to remove config entry")?; + println!("Removed configuration \"{vm}\""); + Ok(()) + } + VmConfig::List => { + println!("Configured VMs:"); + for vm in config.vms.keys() { + println!("{vm}"); + } + Ok(()) + } + }, + }, Commands::RunVm { vm, vnc, @@ -266,7 +302,12 @@ async fn main() -> Result<()> { }; if let Some(mullvad_host) = mullvad_host { - log::trace!("Setting Mullvad host using --mullvad-host flag"); + match config.mullvad_host { + Some(old_host) => { + log::info!("Overriding Mullvad host from {old_host} to {mullvad_host}",) + } + None => log::info!("Setting Mullvad host to {mullvad_host}",), + }; config.mullvad_host = Some(mullvad_host); } let mullvad_host = config.get_host(); @@ -327,7 +368,11 @@ async fn main() -> Result<()> { test_rpc::meta::Os::from(vm_config.os_type), openvpn_certificate, )); - let tests = get_filtered_tests(&test_filters)?; + + let mut tests = get_filtered_tests(&test_filters)?; + for test in tests.iter_mut() { + test.location = config.test_locations.lookup(test.name).cloned(); + } // For convenience, spawn a SOCKS5 server that is reachable for tests that need it let socks = socks_server::spawn(SocketAddr::new( diff --git a/test/test-manager/src/mullvad_daemon.rs b/test/test-manager/src/mullvad_daemon.rs index b03981347001..8da149d32924 100644 --- a/test/test-manager/src/mullvad_daemon.rs +++ b/test/test-manager/src/mullvad_daemon.rs @@ -4,10 +4,7 @@ use std::{io, time::Duration}; use futures::{channel::mpsc, future::BoxFuture, pin_mut, FutureExt, SinkExt, StreamExt}; use hyper_util::rt::TokioIo; use mullvad_management_interface::{ManagementServiceClient, MullvadProxyClient}; -use test_rpc::{ - mullvad_daemon::MullvadClientVersion, - transport::{ConnectionHandle, GrpcForwarder}, -}; +use test_rpc::transport::{ConnectionHandle, GrpcForwarder}; use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream}; use tokio_util::codec::{Decoder, LengthDelimitedCodec}; use tower::Service; @@ -52,20 +49,7 @@ pub struct RpcClientProvider { service: DummyService, } -pub enum MullvadClientArgument { - WithClient(MullvadProxyClient), - None, -} - impl RpcClientProvider { - /// Whether a [test case](test_macro::test_function) needs a [`MullvadProxyClient`]. - pub async fn mullvad_client(&self, client_type: MullvadClientVersion) -> MullvadClientArgument { - match client_type { - MullvadClientVersion::New => MullvadClientArgument::WithClient(self.new_client().await), - MullvadClientVersion::None => MullvadClientArgument::None, - } - } - pub async fn new_client(&self) -> MullvadProxyClient { // FIXME: Ugly workaround to ensure that we don't receive stuff from a // previous RPC session. diff --git a/test/test-manager/src/run_tests.rs b/test/test-manager/src/run_tests.rs index 88577fb3b3cd..54e563f70489 100644 --- a/test/test-manager/src/run_tests.rs +++ b/test/test-manager/src/run_tests.rs @@ -1,12 +1,13 @@ use crate::{ logging::{Logger, Panic, TestOutput, TestResult}, - mullvad_daemon::{self, MullvadClientArgument, RpcClientProvider}, + mullvad_daemon::{self, RpcClientProvider}, summary::SummaryLogger, tests::{self, config::TEST_CONFIG, TestContext, TestMetadata}, vm, }; use anyhow::{Context, Result}; use futures::FutureExt; +use mullvad_management_interface::MullvadProxyClient; use std::{future::Future, panic, time::Duration}; use test_rpc::{logging::Output, ServiceClient}; @@ -33,10 +34,10 @@ impl TestHandler<'_> { &mut self, test: &F, test_name: &'static str, - mullvad_client: MullvadClientArgument, + mullvad_client: Option, ) -> Result<(), anyhow::Error> where - F: Fn(super::tests::TestContext, ServiceClient, MullvadClientArgument) -> R, + F: Fn(super::tests::TestContext, ServiceClient, Option) -> R, R: Future>, { log::info!("Running {test_name}"); @@ -146,26 +147,23 @@ pub async fn run( // expected, and to allow for skipping tests on arbitrary conditions. if TEST_CONFIG.app_package_to_upgrade_from_filename.is_some() { test_handler - .run_test( - &tests::test_upgrade_app, - "test_upgrade_app", - MullvadClientArgument::None, - ) + .run_test(&tests::test_upgrade_app, "test_upgrade_app", None) .await?; } else { log::warn!("No previous app to upgrade from, skipping upgrade test"); }; for test in tests { - tests::prepare_daemon(&test_runner_client, &rpc_provider) + let mut mullvad_client = tests::prepare_daemon(&test_runner_client, &rpc_provider) .await .context("Failed to reset daemon before test")?; - let mullvad_client = rpc_provider - .mullvad_client(test.mullvad_client_version) - .await; + tests::set_test_location(&mut mullvad_client, &test) + .await + .context("Failed to create custom list from test locations")?; + test_handler - .run_test(&test.func, test.name, mullvad_client) + .run_test(&test.func, test.name, Some(mullvad_client)) .await?; } @@ -204,13 +202,13 @@ async fn register_test_result( pub async fn run_test_function( runner_rpc: ServiceClient, - mullvad_rpc: MullvadClientArgument, + mullvad_rpc: Option, test: &F, test_name: &'static str, test_context: super::tests::TestContext, ) -> TestOutput where - F: Fn(super::tests::TestContext, ServiceClient, MullvadClientArgument) -> R, + F: Fn(super::tests::TestContext, ServiceClient, Option) -> R, R: Future>, { let _flushed = runner_rpc.try_poll_output().await; diff --git a/test/test-manager/src/tests/helpers.rs b/test/test-manager/src/tests/helpers.rs index ff581c8f6249..78a7cfacdf88 100644 --- a/test/test-manager/src/tests/helpers.rs +++ b/test/test-manager/src/tests/helpers.rs @@ -17,6 +17,7 @@ use mullvad_relay_selector::{ }; use mullvad_types::{ constraints::Constraint, + custom_list::CustomList, relay_constraints::{ GeographicLocationConstraint, LocationConstraint, RelayConstraints, RelaySettings, }, @@ -1199,160 +1200,48 @@ fn parse_am_i_mullvad(result: String) -> anyhow::Result { }) } -pub mod custom_lists { - use super::*; - - use mullvad_types::custom_list::{CustomList, Id}; - use std::sync::{LazyLock, Mutex}; - - // Expose all custom list variants as a shorthand. - pub use List::*; - - /// The default custom list to use as location for all tests. - pub const DEFAULT_LIST: List = List::Nordic; - - /// Mapping between [List] to daemon custom lists. Since custom list ids are assigned by the - /// daemon at the creation of the custom list settings object, we can't map a custom list - /// name to a specific list before runtime. - static IDS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); - - /// Pre-defined (well-typed) custom lists which may be useful in different test scenarios. - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] - pub enum List { - /// A selection of Nordic servers - Nordic, - /// A selection of European servers - Europe, - /// This custom list contains relays which are close geographically to the computer running - /// the test scenarios, which hopefully means there will be little latency between the test - /// machine and these relays - LowLatency, - /// Antithesis of [List::LowLatency], these relays are located far away from the test - /// server. Use this custom list if you want to simulate scenarios where the probability - /// of experiencing high latencies is desirable. - HighLatency, - } - - impl List { - pub fn name(self) -> String { - use List::*; - match self { - Nordic => "Nordic".to_string(), - Europe => "Europe".to_string(), - LowLatency => "Low Latency".to_string(), - HighLatency => "High Latency".to_string(), - } - } - - /// Iterator over all custom lists. - pub fn all() -> impl Iterator { - use List::*; - [Nordic, Europe, LowLatency, HighLatency].into_iter() - } - - pub fn locations(self) -> impl Iterator { - use List::*; - let country = GeographicLocationConstraint::country; - let city = GeographicLocationConstraint::city; - match self { - Nordic => { - vec![country("no"), country("se"), country("fi"), country("dk")].into_iter() - } - Europe => vec![ - // North - country("se"), - // West - country("fr"), - // East - country("ro"), - // South - country("it"), - ] - .into_iter(), - LowLatency => { - // Assumption: Test server is located in Gothenburg, Sweden. - vec![city("se", "got")].into_iter() - } - HighLatency => { - // Assumption: Test server is located in Gothenburg, Sweden. - vec![country("au"), country("ca"), country("za")].into_iter() - } - } - } - - pub fn to_constraint(self) -> Option { - let ids = IDS.lock().unwrap(); - let id = ids.get(&self)?; - Some(LocationConstraint::CustomList { list_id: *id }) - } - } - - impl From for LocationConstraint { - fn from(custom_list: List) -> Self { - // TODO: Is this _too_ unsound ?? - custom_list.to_constraint().unwrap() - } - } - - /// Add a set of custom lists which can be used in different test scenarios. - /// - /// See [`List`] for available custom lists. - pub async fn add_default_lists(mullvad_client: &mut MullvadProxyClient) -> anyhow::Result<()> { - for custom_list in List::all() { - let id = mullvad_client - .create_custom_list(custom_list.name()) - .await?; - let mut daemon_dito = find_custom_list(mullvad_client, &custom_list.name()).await?; - assert_eq!(id, daemon_dito.id); - for locations in custom_list.locations() { - daemon_dito.locations.insert(locations); - } - mullvad_client.update_custom_list(daemon_dito).await?; - // Associate this custom list variant with a specific, runtime custom list id. - IDS.lock().unwrap().insert(custom_list, id); - } - Ok(()) - } +/// Set the location to the given [`LocationConstraint`]. This also includes +/// entry location for multihop. It does not, however, affect bridge location for OpenVPN. +/// This is for simplify, as bridges default to using the server closest to the exit anyway, and +/// OpenVPN is slated for removal. +/// +/// NOTE: Calling this from within a test will overwrite the default test lcoation specified in +/// the settings. +pub async fn set_location( + mullvad_client: &mut MullvadProxyClient, + location: impl Into, +) -> anyhow::Result<()> { + let constraints = get_location_relay_constraints(location.into()); - /// Set the default location to the custom list specified by `DEFAULT_LIST`. This also includes - /// entry location for multihop. It does not, however, affect bridge location for OpenVPN. - /// This is for simplify, as bridges default to using the server closest to the exit anyway, and - /// OpenVPN is slated for removal. - pub async fn set_default_location( - mullvad_client: &mut MullvadProxyClient, - ) -> anyhow::Result<()> { - let constraints = get_custom_list_location_relay_constraints(DEFAULT_LIST); - - mullvad_client - .set_relay_settings(constraints.into()) - .await - .context("Failed to set relay settings") - } + mullvad_client + .set_relay_settings(constraints.into()) + .await + .context("Failed to set relay settings") +} - fn get_custom_list_location_relay_constraints(custom_list: List) -> RelayConstraints { - let wireguard_constraints = mullvad_types::relay_constraints::WireguardConstraints { - entry_location: Constraint::Only(custom_list.into()), - ..Default::default() - }; +fn get_location_relay_constraints(custom_list: LocationConstraint) -> RelayConstraints { + let wireguard_constraints = mullvad_types::relay_constraints::WireguardConstraints { + entry_location: Constraint::Only(custom_list.clone()), + ..Default::default() + }; - RelayConstraints { - location: Constraint::Only(custom_list.into()), - wireguard_constraints, - ..Default::default() - } + RelayConstraints { + location: Constraint::Only(custom_list), + wireguard_constraints, + ..Default::default() } +} - /// Dig out a custom list from the daemon settings based on the custom list's name. - /// There should be an rpc for this. - async fn find_custom_list( - rpc: &mut MullvadProxyClient, - name: &str, - ) -> anyhow::Result { - rpc.get_settings() - .await? - .custom_lists - .into_iter() - .find(|list| list.name == name) - .ok_or(anyhow!("List '{name}' not found")) - } +/// Dig out a custom list from the daemon settings based on the custom list's name. +/// There should be an rpc for this. +pub async fn find_custom_list( + rpc: &mut MullvadProxyClient, + name: &str, +) -> anyhow::Result { + rpc.get_settings() + .await? + .custom_lists + .into_iter() + .find(|list| list.name == name) + .ok_or(anyhow!("List '{name}' not found")) } diff --git a/test/test-manager/src/tests/install.rs b/test/test-manager/src/tests/install.rs index 936f07d65d79..d640b2608944 100644 --- a/test/test-manager/src/tests/install.rs +++ b/test/test-manager/src/tests/install.rs @@ -6,7 +6,7 @@ use mullvad_types::{constraints::Constraint, relay_constraints}; use test_macro::test_function; use test_rpc::{mullvad_daemon::ServiceStatus, ServiceClient}; -use crate::{mullvad_daemon::MullvadClientArgument, tests::helpers}; +use crate::tests::helpers; use super::{ config::TEST_CONFIG, @@ -25,7 +25,7 @@ use super::{ pub async fn test_upgrade_app( ctx: TestContext, rpc: ServiceClient, - _mullvad_client: MullvadClientArgument, + _mullvad_client: Option, ) -> anyhow::Result<()> { // Install the older version of the app and verify that it is running. let old_version = TEST_CONFIG diff --git a/test/test-manager/src/tests/mod.rs b/test/test-manager/src/tests/mod.rs index 9eb23fe13e8d..e2d50d0889fc 100644 --- a/test/test-manager/src/tests/mod.rs +++ b/test/test-manager/src/tests/mod.rs @@ -16,18 +16,16 @@ mod tunnel_state; mod ui; use itertools::Itertools; +use mullvad_types::relay_constraints::{GeographicLocationConstraint, LocationConstraint}; pub use test_metadata::TestMetadata; use anyhow::Context; use futures::future::BoxFuture; use std::time::Duration; -use crate::{ - mullvad_daemon::{MullvadClientArgument, RpcClientProvider}, - package::get_version_from_path, -}; +use crate::{mullvad_daemon::RpcClientProvider, package::get_version_from_path}; use config::TEST_CONFIG; -use helpers::{get_app_env, install_app}; +use helpers::{find_custom_list, get_app_env, install_app, set_location}; pub use install::test_upgrade_app; use mullvad_management_interface::MullvadProxyClient; use test_rpc::{meta::Os, ServiceClient}; @@ -39,8 +37,11 @@ pub struct TestContext { pub rpc_provider: RpcClientProvider, } -pub type TestWrapperFunction = - fn(TestContext, ServiceClient, MullvadClientArgument) -> BoxFuture<'static, anyhow::Result<()>>; +pub type TestWrapperFunction = fn( + TestContext, + ServiceClient, + Option, +) -> BoxFuture<'static, anyhow::Result<()>>; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -137,7 +138,7 @@ pub fn get_filtered_tests(specified_tests: &[String]) -> Result anyhow::Result<()> { +) -> anyhow::Result { // Check if daemon should be restarted let mut mullvad_client = ensure_daemon_version(rpc, rpc_provider) .await @@ -152,9 +153,60 @@ pub async fn prepare_daemon( .await .context("Failed to disconnect daemon after test")?; helpers::ensure_logged_in(&mut mullvad_client).await?; - helpers::custom_lists::add_default_lists(&mut mullvad_client).await?; - helpers::custom_lists::set_default_location(&mut mullvad_client).await?; + Ok(mullvad_client) +} + +/// Create and selects an "anonymous" custom list for this test. The custom list will +/// have the same name as the test and contain the locations as specified by +/// [`TestMetadata`] location field. +pub async fn set_test_location( + mullvad_client: &mut MullvadProxyClient, + test: &TestMetadata, +) -> anyhow::Result<()> { + // If no location is specified for the test, don't do anything and use the default value of the app + let Some(locations) = test.location.as_ref() else { + return Ok(()); + }; + // Convert locations from the test config to actual location constraints + let locations: Vec = locations + .iter() + .map(|input| { + input + .parse::() + .with_context(|| format!("Failed to parse {input}")) + }) + .try_collect()?; + + log::debug!( + "Creating custom list {} with locations '{:?}'", + test.name, + locations + ); + + // Add the custom list to the current app instance + // NOTE: This const is actually defined in, `mullvad_types::custom_list`, but we cannot import it. + const CUSTOM_LIST_NAME_MAX_SIZE: usize = 30; + let mut custom_list_name = test.name.to_string(); + custom_list_name.truncate(CUSTOM_LIST_NAME_MAX_SIZE); + log::debug!("Creating custom list {custom_list_name} with locations '{locations:?}'"); + + let list_id = mullvad_client + .create_custom_list(custom_list_name.clone()) + .await?; + + let mut custom_list = find_custom_list(mullvad_client, &custom_list_name).await?; + + assert_eq!(list_id, custom_list.id); + for location in locations { + custom_list.locations.insert(location); + } + mullvad_client.update_custom_list(custom_list).await?; + log::debug!("Added custom list"); + + set_location(mullvad_client, LocationConstraint::CustomList { list_id }) + .await + .with_context(|| format!("Failed to set location to custom list with ID '{list_id:?}'"))?; Ok(()) } diff --git a/test/test-manager/src/tests/split_tunnel.rs b/test/test-manager/src/tests/split_tunnel.rs index 435552a460b3..98cbed79516b 100644 --- a/test/test-manager/src/tests/split_tunnel.rs +++ b/test/test-manager/src/tests/split_tunnel.rs @@ -86,7 +86,11 @@ pub async fn test_split_tunnel( /// - A split process should never push traffic through the tunnel. /// - Splitting/unsplitting should work regardless if process is running. #[test_function(target_os = "macos")] -pub async fn test_split_tunnel_ui(_ctx: TestContext, rpc: ServiceClient) -> anyhow::Result<()> { +pub async fn test_split_tunnel_ui( + _ctx: TestContext, + rpc: ServiceClient, + _: MullvadProxyClient, +) -> anyhow::Result<()> { // Skip test on macOS 12, since the feature is unsupported if is_macos_12_or_lower(&rpc).await? { return Ok(()); diff --git a/test/test-manager/src/tests/test_metadata.rs b/test/test-manager/src/tests/test_metadata.rs index 79c7f74def7e..31a55498f9c3 100644 --- a/test/test-manager/src/tests/test_metadata.rs +++ b/test/test-manager/src/tests/test_metadata.rs @@ -1,13 +1,15 @@ use super::TestWrapperFunction; -use test_rpc::{meta::Os, mullvad_daemon::MullvadClientVersion}; +use test_rpc::meta::Os; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct TestMetadata { pub name: &'static str, pub targets: &'static [Os], - pub mullvad_client_version: MullvadClientVersion, pub func: TestWrapperFunction, + /// Priority order of the tests, unless specific tests are given as the `TEST_FILTERS` argument pub priority: Option, + /// A list of location that will be used for by the test + pub location: Option>, } // Register our test metadata struct with inventory to allow submitting tests of this type. diff --git a/test/test-manager/src/tests/tunnel.rs b/test/test-manager/src/tests/tunnel.rs index bc15ccb8a3bb..b64a16d85484 100644 --- a/test/test-manager/src/tests/tunnel.rs +++ b/test/test-manager/src/tests/tunnel.rs @@ -187,15 +187,7 @@ pub async fn test_wireguard_over_shadowsocks( rpc: ServiceClient, mut mullvad_client: MullvadProxyClient, ) -> anyhow::Result<()> { - // NOTE: We have experienced flakiness due to timeout issues if distant relays are selected. - // This is an attempt to try to reduce this type of flakiness. - use helpers::custom_lists::LowLatency; - - let query = RelayQueryBuilder::new() - .wireguard() - .shadowsocks() - .location(LowLatency) - .build(); + let query = RelayQueryBuilder::new().wireguard().shadowsocks().build(); apply_settings_from_relay_query(&mut mullvad_client, query).await?; @@ -294,16 +286,7 @@ pub async fn test_multihop( rpc: ServiceClient, mut mullvad_client: MullvadProxyClient, ) -> Result<(), Error> { - // NOTE: We have experienced flakiness due to timeout issues if distant relays are selected. - // This is an attempt to try to reduce this type of flakiness. - use helpers::custom_lists::LowLatency; - - let query = RelayQueryBuilder::new() - .wireguard() - .multihop() - .location(LowLatency) - .entry(LowLatency) - .build(); + let query = RelayQueryBuilder::new().wireguard().multihop().build(); apply_settings_from_relay_query(&mut mullvad_client, query).await?; @@ -455,10 +438,6 @@ pub async fn test_quantum_resistant_tunnel( rpc: ServiceClient, mut mullvad_client: MullvadProxyClient, ) -> anyhow::Result<()> { - // NOTE: We have experienced flakiness due to timeout issues if distant relays are selected. - // This is an attempt to try to reduce this type of flakiness. - use helpers::custom_lists::LowLatency; - mullvad_client .set_quantum_resistant_tunnel(wireguard::QuantumResistantState::Off) .await @@ -472,10 +451,7 @@ pub async fn test_quantum_resistant_tunnel( log::info!("Setting tunnel protocol to WireGuard"); - let query = RelayQueryBuilder::new() - .wireguard() - .location(LowLatency) - .build(); + let query = RelayQueryBuilder::new().wireguard().build(); apply_settings_from_relay_query(&mut mullvad_client, query).await?; @@ -536,10 +512,6 @@ pub async fn test_quantum_resistant_multihop_udp2tcp_tunnel( rpc: ServiceClient, mut mullvad_client: MullvadProxyClient, ) -> Result<(), Error> { - // NOTE: We have experienced flakiness due to timeout issues if distant relays are selected. - // This is an attempt to try to reduce this type of flakiness. - use helpers::custom_lists::LowLatency; - mullvad_client .set_quantum_resistant_tunnel(wireguard::QuantumResistantState::On) .await @@ -549,8 +521,6 @@ pub async fn test_quantum_resistant_multihop_udp2tcp_tunnel( .wireguard() .multihop() .udp2tcp() - .entry(LowLatency) - .location(LowLatency) .build(); apply_settings_from_relay_query(&mut mullvad_client, query).await?; @@ -577,10 +547,6 @@ pub async fn test_quantum_resistant_multihop_shadowsocks_tunnel( rpc: ServiceClient, mut mullvad_client: MullvadProxyClient, ) -> anyhow::Result<()> { - // NOTE: We have experienced flakiness due to timeout issues if distant relays are selected. - // This is an attempt to try to reduce this type of flakiness. - use helpers::custom_lists::LowLatency; - mullvad_client .set_quantum_resistant_tunnel(wireguard::QuantumResistantState::On) .await @@ -590,8 +556,6 @@ pub async fn test_quantum_resistant_multihop_shadowsocks_tunnel( .wireguard() .multihop() .shadowsocks() - .entry(LowLatency) - .location(LowLatency) .build(); apply_settings_from_relay_query(&mut mullvad_client, query).await?; diff --git a/test/test-manager/src/tests/ui.rs b/test/test-manager/src/tests/ui.rs index 6b9ce5b3a9b6..088fd1e55d73 100644 --- a/test/test-manager/src/tests/ui.rs +++ b/test/test-manager/src/tests/ui.rs @@ -91,19 +91,11 @@ pub async fn test_ui_tunnel_settings( rpc: ServiceClient, mut mullvad_client: MullvadProxyClient, ) -> anyhow::Result<()> { - // NOTE: This test connects multiple times using various settings, some of which may cause a - // significant increase in connection time, e.g. multihop and OpenVPN. For this reason, it is - // preferable to only target low latency servers. - use helpers::custom_lists::LowLatency; - // tunnel-state.spec precondition: a single WireGuard relay should be selected log::info!("Select WireGuard relay"); let entry = helpers::constrain_to_relay( &mut mullvad_client, - RelayQueryBuilder::new() - .wireguard() - .location(LowLatency) - .build(), + RelayQueryBuilder::new().wireguard().build(), ) .await?; @@ -273,7 +265,11 @@ async fn test_custom_bridge_gui( /// Test settings import / IP overrides in the GUI #[test_function] -pub async fn test_import_settings_ui(_: TestContext, rpc: ServiceClient) -> Result<(), Error> { +pub async fn test_import_settings_ui( + _: TestContext, + rpc: ServiceClient, + _: MullvadProxyClient, +) -> Result<(), Error> { let ui_result = run_test(&rpc, &["settings-import.spec"]).await?; assert!(ui_result.success()); Ok(()) @@ -281,7 +277,11 @@ pub async fn test_import_settings_ui(_: TestContext, rpc: ServiceClient) -> Resu /// Test obfuscation settings in the GUI #[test_function] -pub async fn test_obfuscation_settings_ui(_: TestContext, rpc: ServiceClient) -> Result<(), Error> { +pub async fn test_obfuscation_settings_ui( + _: TestContext, + rpc: ServiceClient, + _: MullvadProxyClient, +) -> Result<(), Error> { let ui_result = run_test(&rpc, &["obfuscation.spec"]).await?; assert!(ui_result.success()); Ok(()) @@ -289,7 +289,11 @@ pub async fn test_obfuscation_settings_ui(_: TestContext, rpc: ServiceClient) -> /// Test settings in the GUI #[test_function] -pub async fn test_settings_ui(_: TestContext, rpc: ServiceClient) -> Result<(), Error> { +pub async fn test_settings_ui( + _: TestContext, + rpc: ServiceClient, + _: MullvadProxyClient, +) -> Result<(), Error> { let ui_result = run_test(&rpc, &["settings.spec"]).await?; assert!(ui_result.success()); Ok(()) diff --git a/test/test-manager/test_macro/src/lib.rs b/test/test-manager/test_macro/src/lib.rs index cddb6c5a2f6d..048bb1975ef6 100644 --- a/test/test-manager/test_macro/src/lib.rs +++ b/test/test-manager/test_macro/src/lib.rs @@ -91,11 +91,9 @@ fn parse_marked_test_function( function: &syn::ItemFn, ) -> Result { let macro_parameters = get_test_macro_parameters(attributes)?; - let function_parameters = get_test_function_parameters(&function.sig.inputs)?; Ok(TestFunction { name: function.sig.ident.clone(), - function_parameters, macro_parameters, }) } @@ -153,34 +151,15 @@ fn create_test(test_function: TestFunction) -> proc_macro2::TokenStream { .collect(); let func_name = test_function.name; - let function_mullvad_version = test_function.function_parameters.mullvad_client.version(); - let wrapper_closure = match test_function.function_parameters.mullvad_client { - MullvadClient::New { .. } => { - quote! { - |test_context: crate::tests::TestContext, - rpc: test_rpc::ServiceClient, - mullvad_client: crate::mullvad_daemon::MullvadClientArgument| - { - let mullvad_client = match mullvad_client { - crate::mullvad_daemon::MullvadClientArgument::WithClient(client) => client, - crate::mullvad_daemon::MullvadClientArgument::None => unreachable!("invalid mullvad client") - }; - Box::pin(async move { - #func_name(test_context, rpc, mullvad_client).await.map_err(Into::into) - }) - } - } - } - MullvadClient::None { .. } => { - quote! { - |test_context: crate::tests::TestContext, - rpc: test_rpc::ServiceClient, - _mullvad_client: crate::mullvad_daemon::MullvadClientArgument| { - Box::pin(async move { - #func_name(test_context, rpc).await.map_err(Into::into) - }) - } - } + let wrapper_closure = quote! { + |test_context: crate::tests::TestContext, + rpc: test_rpc::ServiceClient, + mullvad_client: Option| + { + let mullvad_client = mullvad_client.expect("Test functions defined using the macro should be given a mullvad client"); + Box::pin(async move { + #func_name(test_context, rpc, mullvad_client).await.map_err(Into::into) + }) } }; @@ -188,16 +167,15 @@ fn create_test(test_function: TestFunction) -> proc_macro2::TokenStream { inventory::submit!(crate::tests::test_metadata::TestMetadata { name: stringify!(#func_name), targets: &[#targets], - mullvad_client_version: #function_mullvad_version, func: #wrapper_closure, priority: #test_function_priority, + location: None, }); } } struct TestFunction { name: syn::Ident, - function_parameters: FunctionParameters, macro_parameters: MacroParameters, } @@ -205,66 +183,3 @@ struct MacroParameters { priority: Option, targets: Vec, } - -enum MullvadClient { - None { - mullvad_client_version: proc_macro2::TokenStream, - }, - New { - mullvad_client_version: proc_macro2::TokenStream, - }, -} - -impl MullvadClient { - fn version(&self) -> proc_macro2::TokenStream { - match self { - MullvadClient::None { - mullvad_client_version, - } => mullvad_client_version.clone(), - MullvadClient::New { - mullvad_client_version, - .. - } => mullvad_client_version.clone(), - } - } -} - -struct FunctionParameters { - mullvad_client: MullvadClient, -} - -fn get_test_function_parameters( - args: &syn::punctuated::Punctuated, -) -> Result { - if args.len() <= 2 { - return Ok(FunctionParameters { - mullvad_client: MullvadClient::None { - mullvad_client_version: quote! { - test_rpc::mullvad_daemon::MullvadClientVersion::None - }, - }, - }); - } - - let arg = args[2].clone(); - let syn::FnArg::Typed(pat_type) = arg else { - bail!(arg, "unexpected 'mullvad_client' arg"); - }; - - let syn::Type::Path(syn::TypePath { path, .. }) = &*pat_type.ty else { - bail!(pat_type, "unexpected 'mullvad_client' type"); - }; - - let mullvad_client = match path.segments[0].ident.to_string().as_str() { - "mullvad_management_interface" | "MullvadProxyClient" => { - let mullvad_client_version = - quote! { test_rpc::mullvad_daemon::MullvadClientVersion::New }; - MullvadClient::New { - mullvad_client_version, - } - } - _ => bail!(pat_type, "cannot infer mullvad client type"), - }; - - Ok(FunctionParameters { mullvad_client }) -} diff --git a/test/test-rpc/src/mullvad_daemon.rs b/test/test-rpc/src/mullvad_daemon.rs index 10cc00c3fc96..3a62fa7c6acd 100644 --- a/test/test-rpc/src/mullvad_daemon.rs +++ b/test/test-rpc/src/mullvad_daemon.rs @@ -26,9 +26,3 @@ pub enum Verbosity { Debug, Trace, } - -#[derive(Clone, Copy, PartialEq)] -pub enum MullvadClientVersion { - None, - New, -}