From ad083677da0cfdbbc5f5b081def2e373b6953c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Wed, 8 Nov 2023 12:37:54 +0000 Subject: [PATCH] Implement Backup client --- Cargo.toml | 1 + graphql/Cargo.toml | 2 +- graphql/schemas/operations.graphql | 14 ++ graphql/schemas/schema_wallet_read.graphql | 17 ++ graphql/src/lib.rs | 45 ++++- graphql/src/schema.rs | 16 ++ honey-badger/src/lib.rs | 17 ++ honey-badger/src/provider.rs | 210 ++++++++++++++++++++- squirrel/Cargo.toml | 19 ++ squirrel/src/lib.rs | 69 +++++++ squirrel/tests/integration_tests.rs | 131 +++++++++++++ 11 files changed, 538 insertions(+), 3 deletions(-) create mode 100644 squirrel/Cargo.toml create mode 100644 squirrel/src/lib.rs create mode 100644 squirrel/tests/integration_tests.rs diff --git a/Cargo.toml b/Cargo.toml index bf2adf8..955063e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,4 +5,5 @@ members = [ "graphql", "honey-badger", "mole", + "squirrel", ] diff --git a/graphql/Cargo.toml b/graphql/Cargo.toml index 14162dd..66c045d 100644 --- a/graphql/Cargo.toml +++ b/graphql/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] chrono = { version = "0.4.24", default-features = false, features = ["std"] } -graphql_client = { version = "0.13.0", features = ["reqwest-blocking"]} +graphql_client = { version = "0.13.0", features = ["reqwest-blocking", "reqwest"]} log = "0.4.17" reqwest = { version = "0.11", default-features = false, features = ["json", "blocking", "rustls-tls"]} serde = { version = "1.0", features = ["derive"]} diff --git a/graphql/schemas/operations.graphql b/graphql/schemas/operations.graphql index 9e0065c..3763e8e 100644 --- a/graphql/schemas/operations.graphql +++ b/graphql/schemas/operations.graphql @@ -172,3 +172,17 @@ query MigrationBalance($nodePubKey: String) { mutation MigrateFunds($invoice: String, $base16InvoiceSignature: String, $ldkNodePubKey: String) { migrate_funds(invoice: $invoice, base16InvoiceSignature: $base16InvoiceSignature, ldkNodePubKey: $ldkNodePubKey) } + +mutation CreateBackup($encryptedBackup: String!, $schemaName: String!, $schemaVersion: String!) { + create_backup(encryptedBackup: $encryptedBackup, schemaName: $schemaName, schemaVersion: $schemaVersion) { + updatedAt + } +} + +mutation RecoverBackup($schemaName: String!) { + recover_backup(schemaName: $schemaName) { + encryptedBackup + schemaVersion + updatedAt + } +} diff --git a/graphql/schemas/schema_wallet_read.graphql b/graphql/schemas/schema_wallet_read.graphql index b67c243..953d583 100644 --- a/graphql/schemas/schema_wallet_read.graphql +++ b/graphql/schemas/schema_wallet_read.graphql @@ -802,6 +802,8 @@ type mutation_root { ): accepted_terms_conditions accept_wallet_acl_by_pk(pk_columns: AcceptWalletPkRequestInput!): WalletAcl + create_backup(encryptedBackup: String!, schemaName: String!, schemaVersion: String!): CreateBackupResponse + """ delete data from the table: "channel_manager" """ @@ -895,6 +897,7 @@ type mutation_root { on_conflict: channel_monitor_on_conflict ): channel_monitor migrate_funds(base16InvoiceSignature: String, invoice: String, ldkNodePubKey: String): Boolean! + recover_backup(schemaName: String!): RecoverBackupResponse refresh_session(refreshToken: String!): TokenContainer refresh_session_v2(refreshToken: String!): SessionPermit register_email(email: String): WalletEmail @@ -1787,3 +1790,17 @@ input wallet_acl_stream_cursor_value_input { ownerWalletPubKeyId: uuid role: String } + +type CreateBackupResponse { + walletId: String + schemaName: String + schemaVersion: String + updatedAt: timestamptz +} + +type RecoverBackupResponse { + walletId: String + encryptedBackup: String + schemaVersion: String + updatedAt: timestamptz +} diff --git a/graphql/src/lib.rs b/graphql/src/lib.rs index 6f9074c..c263379 100644 --- a/graphql/src/lib.rs +++ b/graphql/src/lib.rs @@ -6,7 +6,7 @@ pub use crate::errors::*; pub use perro; pub use reqwest; -use graphql_client::reqwest::post_graphql_blocking; +use graphql_client::reqwest::{post_graphql, post_graphql_blocking}; use graphql_client::Response; use perro::{permanent_failure, runtime_error, MapToError, OptionToError}; use reqwest::blocking::Client; @@ -37,6 +37,25 @@ pub fn build_client(access_token: Option<&str>) -> Result { Ok(client) } +pub fn build_async_client(access_token: Option<&str>) -> Result { + let user_agent = "graphql-rust/0.12.0"; + let timeout = Duration::from_secs(20); + + let mut builder = reqwest::Client::builder() + .user_agent(user_agent) + .timeout(timeout); + if let Some(access_token) = access_token { + let value = HeaderValue::from_str(&format!("Bearer {access_token}")) + .map_to_permanent_failure("Failed to build header value from str")?; + builder = builder.default_headers(std::iter::once((AUTHORIZATION, value)).collect()); + } + + let client = builder + .build() + .map_to_permanent_failure("Failed to build a async reqwest client")?; + Ok(client) +} + pub fn post_blocking( client: &Client, backend_url: &String, @@ -61,6 +80,30 @@ pub fn post_blocking( get_response_data(response, backend_url) } +pub async fn post( + client: &reqwest::Client, + backend_url: &String, + variables: Query::Variables, +) -> Result { + let response = match post_graphql::(client, backend_url, variables).await { + Ok(r) => r, + Err(e) => { + if is_502_status(e.status()) || e.to_string().contains("502") { + // checking for the error containing 502 because reqwest is unexpectedly returning a decode error instead of status error + return Err(runtime_error( + GraphQlRuntimeErrorCode::RemoteServiceUnavailable, + "The remote server returned status 502", + )); + } + return Err(runtime_error( + GraphQlRuntimeErrorCode::NetworkError, + "Failed to execute the query", + )); + } + }; + get_response_data(response, backend_url) +} + pub fn parse_from_rfc3339(rfc3339: &str) -> Result { let datetime = chrono::DateTime::parse_from_rfc3339(rfc3339).map_to_runtime_error( GraphQlRuntimeErrorCode::CorruptData, diff --git a/graphql/src/schema.rs b/graphql/src/schema.rs index e10f9a1..2a03a55 100644 --- a/graphql/src/schema.rs +++ b/graphql/src/schema.rs @@ -190,3 +190,19 @@ pub struct MigrationBalance; response_derives = "Debug" )] pub struct MigrateFunds; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "schemas/schema_wallet_read.graphql", + query_path = "schemas/operations.graphql", + response_derives = "Debug" +)] +pub struct CreateBackup; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "schemas/schema_wallet_read.graphql", + query_path = "schemas/operations.graphql", + response_derives = "Debug" +)] +pub struct RecoverBackup; diff --git a/honey-badger/src/lib.rs b/honey-badger/src/lib.rs index 8ff4c43..7164f51 100644 --- a/honey-badger/src/lib.rs +++ b/honey-badger/src/lib.rs @@ -63,6 +63,23 @@ impl Auth { .ok_or_permanent_failure("Newly refreshed token is not valid long enough") } + pub async fn async_query_token(&self) -> Result { + if let Some(token) = self.get_token_if_valid() { + return Ok(token); + } + + let mut provider = self.provider.lock().unwrap(); + // Anyone else refreshed the token by chance?... + if let Some(token) = self.get_token_if_valid() { + return Ok(token); + } + + let token = adjust_token(provider.async_query_token().await?)?; + *self.token.lock().unwrap() = token; + self.get_token_if_valid() + .ok_or_permanent_failure("Newly refreshed token is not valid long enough") + } + pub fn get_wallet_pubkey_id(&self) -> Option { self.provider.lock().unwrap().get_wallet_pubkey_id() } diff --git a/honey-badger/src/provider.rs b/honey-badger/src/provider.rs index d0c7bb2..c8f3bb7 100644 --- a/honey-badger/src/provider.rs +++ b/honey-badger/src/provider.rs @@ -4,7 +4,7 @@ use crate::signing::sign; use graphql::perro::{invalid_input, permanent_failure, runtime_error, OptionToError}; use graphql::reqwest::blocking::Client; use graphql::schema::*; -use graphql::{build_client, post_blocking}; +use graphql::{build_async_client, build_client, post, post_blocking, reqwest}; use graphql::{errors::*, parse_from_rfc3339}; use log::info; use std::time::SystemTime; @@ -28,6 +28,7 @@ pub(crate) struct AuthProvider { wallet_keypair: KeyPair, auth_keypair: KeyPair, client: Client, + async_client: reqwest::Client, refresh_token: Option, wallet_pubkey_id: Option, } @@ -40,12 +41,14 @@ impl AuthProvider { auth_keypair: KeyPair, ) -> Result { let client = build_client(None)?; + let async_client = build_async_client(None)?; Ok(AuthProvider { backend_url, auth_level, wallet_keypair, auth_keypair, client, + async_client, refresh_token: None, wallet_pubkey_id: None, }) @@ -69,6 +72,24 @@ impl AuthProvider { Ok(access_token) } + pub async fn async_query_token(&mut self) -> Result { + let (access_token, refresh_token) = match self.refresh_token.clone() { + Some(refresh_token) => { + match self.async_refresh_session(refresh_token).await { + // Tolerate authentication errors and retry auth flow. + Err(Error::RuntimeError { + code: GraphQlRuntimeErrorCode::AuthServiceError, + .. + }) => self.async_run_auth_flow().await, + result => result, + } + } + None => self.async_run_auth_flow().await, + }?; + self.refresh_token = Some(refresh_token); + Ok(access_token) + } + pub fn get_wallet_pubkey_id(&self) -> Option { self.wallet_pubkey_id.clone() } @@ -153,6 +174,28 @@ impl AuthProvider { } } + async fn async_run_auth_flow(&mut self) -> Result<(String, String)> { + let (access_token, refresh_token, wallet_pub_key_id) = + self.async_start_basic_session().await?; + + self.wallet_pubkey_id = Some(wallet_pub_key_id.clone()); + + match self.auth_level { + AuthLevel::Pseudonymous => Ok((access_token, refresh_token)), + AuthLevel::Owner => { + self.async_start_priviledged_session(access_token, wallet_pub_key_id) + .await + } + AuthLevel::Employee => { + let owner_pub_key_id = self + .async_get_business_owner(access_token.clone(), wallet_pub_key_id) + .await?; + self.async_start_priviledged_session(access_token, owner_pub_key_id) + .await + } + } + } + fn start_basic_session(&self) -> Result<(String, String, String)> { let challenge = self.request_challenge()?; @@ -196,6 +239,49 @@ impl AuthProvider { Ok((access_token, refresh_token, wallet_pub_key_id)) } + async fn async_start_basic_session(&self) -> Result<(String, String, String)> { + let challenge = self.async_request_challenge().await?; + + let challenge_with_prefix = add_bitcoin_message_prefix(&challenge); + let challenge_signature = sign(challenge_with_prefix, self.auth_keypair.secret_key.clone()); + + let auth_pub_key_with_prefix = add_hex_prefix(&self.auth_keypair.public_key); + let signed_auth_pub_key = sign( + auth_pub_key_with_prefix, + self.wallet_keypair.secret_key.clone(), + ); + + info!("Starting session ..."); + let variables = start_session::Variables { + auth_pub_key: add_hex_prefix(&self.auth_keypair.public_key), + challenge, + challenge_signature: add_hex_prefix(&challenge_signature), + wallet_pub_key: add_hex_prefix(&self.wallet_keypair.public_key), + signed_auth_pub_key: add_hex_prefix(&signed_auth_pub_key), + }; + + let data = post::(&self.async_client, &self.backend_url, variables).await?; + + let session_permit = data.start_session_v2.ok_or_permanent_failure( + "Response to start_session request doesn't have the expected structure", + )?; + let access_token = session_permit.access_token.ok_or_permanent_failure( + "Response to start_session request doesn't have the expected structure: missing access token", + )?; + let refresh_token = session_permit.refresh_token.ok_or_permanent_failure( + "Response to start_session request doesn't have the expected structure: missing refresh token", + )?; + let wallet_pub_key_id = session_permit.wallet_pub_key_id.ok_or_permanent_failure( + "Response to start_session request doesn't have the expected structure: missing wallet public key id", + )?; + #[cfg(debug_assertions)] + info!("access_token: {}", access_token); + #[cfg(debug_assertions)] + info!("refresh_token: {}", refresh_token); + info!("wallet_pub_key_id: {}", wallet_pub_key_id); + Ok((access_token, refresh_token, wallet_pub_key_id)) + } + fn start_priviledged_session( &self, access_token: String, @@ -249,6 +335,59 @@ impl AuthProvider { Ok((access_token, refresh_token)) } + async fn async_start_priviledged_session( + &self, + access_token: String, + owner_pub_key_id: String, + ) -> Result<(String, String)> { + let challenge = self.async_request_challenge().await?; + + let challenge_with_prefix = add_bitcoin_message_prefix(&challenge); + let challenge_signature = sign( + challenge_with_prefix, + self.wallet_keypair.secret_key.clone(), + ); + + info!("Preparing wallet session ..."); + let variables = prepare_wallet_session::Variables { + wallet_pub_key_id: owner_pub_key_id, + challenge: challenge.clone(), + signed_challenge: add_hex_prefix(&challenge_signature), + }; + + let client = build_async_client(Some(&access_token))?; + let data = post::(&client, &self.backend_url, variables).await?; + + let prepared_permission_token = data.prepare_wallet_session.ok_or_permanent_failure( + "Response to prepare_wallet_session request doesn't have the expected structure", + )?; + + info!("Starting wallet session ..."); + let variables = unlock_wallet::Variables { + challenge, + challenge_signature: add_hex_prefix(&challenge_signature), + prepared_permission_token, + }; + let data = post::(&client, &self.backend_url, variables).await?; + + let session_permit = data.start_prepared_session.ok_or_permanent_failure( + "Response to unlock_wallet request doesn't have the expected structure", + )?; + let access_token = session_permit.access_token.ok_or_permanent_failure( + "Response to unlock_wallet request doesn't have the expected structure: missing access token", + )?; + let refresh_token = session_permit.refresh_token.ok_or_permanent_failure( + "Response to unlock_wallet request doesn't have the expected structure: missing refresh token", + )?; + + #[cfg(debug_assertions)] + info!("access_token: {}", access_token); + #[cfg(debug_assertions)] + info!("refresh_token: {}", refresh_token); + + Ok((access_token, refresh_token)) + } + fn get_business_owner( &self, access_token: String, @@ -279,6 +418,36 @@ impl AuthProvider { Ok(result.owner_wallet_pub_key_id.clone()) } + async fn async_get_business_owner( + &self, + access_token: String, + wallet_pub_key_id: String, + ) -> Result { + info!("Getting business owner ..."); + let variables = get_business_owner::Variables { + owner_wallet_pub_key_id: wallet_pub_key_id, + }; + let client = build_async_client(Some(&access_token))?; + let data = post::(&client, &self.backend_url, variables).await?; + + let result = data + .wallet_acl + .first() + .ok_or_invalid_input("Employee does not belong to any owner")?; + + if let Some(access_expires_at) = result.access_expires_at.as_ref() { + let access_expires_at = parse_from_rfc3339(access_expires_at)?; + if SystemTime::now() > access_expires_at { + return Err(runtime_error( + GraphQlRuntimeErrorCode::AccessExpired, + "Access expired", + )); + } + } + info!("Owner: {:?}", result.owner_wallet_pub_key_id); + Ok(result.owner_wallet_pub_key_id.clone()) + } + fn refresh_session(&self, refresh_token: String) -> Result<(String, String)> { // Refresh session. info!("Refreshing session ..."); @@ -303,6 +472,30 @@ impl AuthProvider { Ok((access_token, refresh_token)) } + async fn async_refresh_session(&self, refresh_token: String) -> Result<(String, String)> { + // Refresh session. + info!("Refreshing session ..."); + let variables = refresh_session::Variables { refresh_token }; + let data = post::(&self.async_client, &self.backend_url, variables).await?; + + let session_permit = data.refresh_session.ok_or_permanent_failure( + "Response to refresh_session request doesn't have the expected structure", + )?; + let access_token = session_permit.access_token.ok_or_permanent_failure( + "Response to unlock_wallet request doesn't have the expected structure: missing access token", + )?; + let refresh_token = session_permit.refresh_token.ok_or_permanent_failure( + "Response to unlock_wallet request doesn't have the expected structure: missing refresh token", + )?; + + #[cfg(debug_assertions)] + info!("access_token: {}", access_token); + #[cfg(debug_assertions)] + info!("refresh_token: {}", refresh_token); + + Ok((access_token, refresh_token)) + } + fn request_challenge(&self) -> Result { info!("Requesting challenge ..."); let variables = request_challenge::Variables {}; @@ -316,6 +509,21 @@ impl AuthProvider { Ok(challenge) } + + async fn async_request_challenge(&self) -> Result { + info!("Requesting challenge ..."); + let variables = request_challenge::Variables {}; + let data = + post::(&self.async_client, &self.backend_url, variables).await?; + + let challenge = data + .auth_challenge + .ok_or_permanent_failure( + "Response to request_challenge request doesn't have the expected structure: missing auth challenge", + )?; + + Ok(challenge) + } } fn add_hex_prefix(string: &str) -> String { diff --git a/squirrel/Cargo.toml b/squirrel/Cargo.toml new file mode 100644 index 0000000..3acf710 --- /dev/null +++ b/squirrel/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "squirrel" +version = "0.1.0" +edition = "2021" + +[dependencies] +hex = "0.4.3" +log = "0.4.17" + +graphql = { path = "../graphql" } +honey-badger = { path = "../honey-badger" } + +[dev-dependencies] +bitcoin = { version = "0.29.2" } +ctor = "0.2.0" +rand = "0.8.5" +simplelog = { version ="0.12.0", features = ["test"] } +serial_test = "2.0.0" +tokio = "1.32.0" diff --git a/squirrel/src/lib.rs b/squirrel/src/lib.rs new file mode 100644 index 0000000..1ba712d --- /dev/null +++ b/squirrel/src/lib.rs @@ -0,0 +1,69 @@ +use graphql::errors::*; +use graphql::perro::{runtime_error, MapToError}; +use graphql::schema::*; +use graphql::{build_async_client, post}; +use honey_badger::Auth; +use std::sync::Arc; + +#[derive(Debug, PartialEq)] +pub struct Backup { + pub encrypted_backup: Vec, + pub schema_name: String, + pub schema_version: String, +} + +pub struct RemoteBackupClient { + backend_url: String, + auth: Arc, +} + +impl RemoteBackupClient { + pub fn new(backend_url: String, auth: Arc) -> Self { + Self { backend_url, auth } + } + + pub async fn create_backup(&self, backup: &Backup) -> Result<()> { + let token = self.auth.query_token()?; + let client = build_async_client(Some(&token))?; + let variables = create_backup::Variables { + encrypted_backup: graphql_hex_encode(&backup.encrypted_backup), + schema_name: backup.schema_name.clone(), + schema_version: backup.schema_version.clone(), + }; + post::(&client, &self.backend_url, variables).await?; + + Ok(()) + } + + pub async fn recover_backup(&self, schema_name: &str) -> Result { + let token = self.auth.query_token()?; + let client = build_async_client(Some(&token))?; + let variables = recover_backup::Variables { + schema_name: schema_name.to_string(), + }; + let data = post::(&client, &self.backend_url, variables).await?; + + match data.recover_backup { + None => Err(runtime_error( + GraphQlRuntimeErrorCode::ObjectNotFound, + "No backup found", + )), + Some(d) => Ok(Backup { + encrypted_backup: graphql_hex_decode(&d.encrypted_backup.unwrap()).unwrap(), + schema_name: schema_name.to_string(), + schema_version: d.schema_version.unwrap(), + }), + } + } +} + +fn graphql_hex_encode(data: &Vec) -> String { + format!("\\x{}", hex::encode(data)) +} + +fn graphql_hex_decode(data: &str) -> Result> { + hex::decode(data.replacen("\\x", "", 1)).map_to_runtime_error( + GraphQlRuntimeErrorCode::CorruptData, + "Could not decode hex encoded binary", + ) +} diff --git a/squirrel/tests/integration_tests.rs b/squirrel/tests/integration_tests.rs new file mode 100644 index 0000000..fa3277c --- /dev/null +++ b/squirrel/tests/integration_tests.rs @@ -0,0 +1,131 @@ +use bitcoin::Network; +use graphql::perro::Error::RuntimeError; +use graphql::GraphQlRuntimeErrorCode; +use honey_badger::secrets::{derive_keys, generate_keypair, generate_mnemonic}; +use honey_badger::{Auth, AuthLevel}; +use rand::random; +use simplelog::TestLogger; +use squirrel::{Backup, RemoteBackupClient}; +use std::env; +use std::sync::{Arc, Once}; + +static INIT_LOGGER_ONCE: Once = Once::new(); + +#[cfg(test)] +#[ctor::ctor] +fn init() { + INIT_LOGGER_ONCE.call_once(|| { + TestLogger::init(simplelog::LevelFilter::Info, simplelog::Config::default()).unwrap(); + }); +} + +#[tokio::test] +async fn test_recovering_backup_when_there_is_none() { + let client = build_backup_client(); + + let result = client.recover_backup("a").await; + + assert!(result.is_err()); + assert!(matches!( + result, + Err(RuntimeError { + code: GraphQlRuntimeErrorCode::ObjectNotFound, + .. + }) + )); +} + +#[tokio::test] +async fn test_backup_persistence() { + let client = build_backup_client(); + let dummy_backup_schema_a_version_1 = Backup { + encrypted_backup: random::<[u8; 32]>().to_vec(), + schema_name: "a".to_string(), + schema_version: "1".to_string(), + }; + let dummy_backup_schema_a_version_2 = Backup { + encrypted_backup: random::<[u8; 32]>().to_vec(), + schema_name: "a".to_string(), + schema_version: "2".to_string(), + }; + let dummy_backup_schema_b_version_1 = Backup { + encrypted_backup: random::<[u8; 32]>().to_vec(), + schema_name: "b".to_string(), + schema_version: "1".to_string(), + }; + let dummy_backup_schema_b_version_2 = Backup { + encrypted_backup: random::<[u8; 32]>().to_vec(), + schema_name: "b".to_string(), + schema_version: "2".to_string(), + }; + + client + .create_backup(&dummy_backup_schema_a_version_1) + .await + .unwrap(); + client + .create_backup(&dummy_backup_schema_b_version_1) + .await + .unwrap(); + + assert_eq!( + client.recover_backup("a").await.unwrap(), + dummy_backup_schema_a_version_1 + ); + assert_eq!( + client.recover_backup("b").await.unwrap(), + dummy_backup_schema_b_version_1 + ); + + client + .create_backup(&dummy_backup_schema_a_version_2) + .await + .unwrap(); + client + .create_backup(&dummy_backup_schema_b_version_2) + .await + .unwrap(); + + assert_eq!( + client.recover_backup("a").await.unwrap(), + dummy_backup_schema_a_version_2 + ); + assert_eq!( + client.recover_backup("b").await.unwrap(), + dummy_backup_schema_b_version_2 + ); + + // non-existing schema name + let result = client.recover_backup("c").await; + + assert!(result.is_err()); + assert!(matches!( + result, + Err(RuntimeError { + code: GraphQlRuntimeErrorCode::ObjectNotFound, + .. + }) + )); +} + +fn build_backup_client() -> RemoteBackupClient { + println!("Generating keys ..."); + let mnemonic = generate_mnemonic(); + println!("mnemonic: {mnemonic:?}"); + let wallet_keys = derive_keys(Network::Testnet, mnemonic).wallet_keypair; + let auth_keys = generate_keypair(); + + let auth = Auth::new( + get_backend_url(), + AuthLevel::Pseudonymous, + wallet_keys, + auth_keys, + ) + .unwrap(); + + RemoteBackupClient::new(get_backend_url(), Arc::new(auth)) +} + +fn get_backend_url() -> String { + env::var("GRAPHQL_API_URL").expect("GRAPHQL_API_URL environment variable is not set") +}