From 2b589237dcbaba982b7dc701f08644c25c2037d5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 24 Apr 2023 17:31:02 +0100 Subject: [PATCH] tests: [#111] E2E test for user routes --- compose.yaml | 10 + config-idx-back.toml.local | 6 +- config-tracker.toml.local | 2 +- project-words.txt | 1 + src/mailer.rs | 22 +- src/routes/user.rs | 8 + tests/e2e/asserts.rs | 12 +- tests/e2e/client.rs | 99 +++++-- tests/e2e/connection_info.rs | 13 + tests/e2e/{routes => contexts}/about.rs | 2 +- tests/e2e/{routes => contexts}/category.rs | 5 +- tests/e2e/{routes => contexts}/mod.rs | 1 + tests/e2e/{routes => contexts}/root.rs | 2 +- tests/e2e/contexts/user.rs | 314 +++++++++++++++++++++ tests/e2e/{env.rs => environment.rs} | 6 +- tests/e2e/http.rs | 2 +- tests/e2e/mod.rs | 18 +- tests/e2e/response.rs | 7 +- 18 files changed, 482 insertions(+), 48 deletions(-) rename tests/e2e/{routes => contexts}/about.rs (94%) rename tests/e2e/{routes => contexts}/category.rs (82%) rename tests/e2e/{routes => contexts}/mod.rs (77%) rename tests/e2e/{routes => contexts}/root.rs (90%) create mode 100644 tests/e2e/contexts/user.rs rename tests/e2e/{env.rs => environment.rs} (62%) diff --git a/compose.yaml b/compose.yaml index 35447943..8c672f00 100644 --- a/compose.yaml +++ b/compose.yaml @@ -33,6 +33,8 @@ services: - ~/.cargo:/home/appuser/.cargo depends_on: - tracker + - mailcatcher + - mysql tracker: image: torrust/tracker:develop @@ -62,6 +64,14 @@ services: depends_on: - mysql + mailcatcher: + image: dockage/mailcatcher:0.8.2 + networks: + - server_side + ports: + - 1080:1080 + - 1025:1025 + mysql: image: mysql:8.0 command: '--default-authentication-plugin=mysql_native_password' diff --git a/config-idx-back.toml.local b/config-idx-back.toml.local index 1051dcb9..5d1ff7e8 100644 --- a/config-idx-back.toml.local +++ b/config-idx-back.toml.local @@ -18,7 +18,7 @@ max_password_length = 64 secret_key = "MaxVerstappenWC2021" [database] -connect_url = "sqlite://storage/database/data.db?mode=rwc" # SQLite +connect_url = "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc" # SQLite #connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend" # MySQL torrent_info_update_interval = 3600 @@ -28,5 +28,5 @@ from = "example@email.com" reply_to = "noreply@email.com" username = "" password = "" -server = "" -port = 25 +server = "mailcatcher" +port = 1025 diff --git a/config-tracker.toml.local b/config-tracker.toml.local index 82ceb285..9db1b578 100644 --- a/config-tracker.toml.local +++ b/config-tracker.toml.local @@ -1,7 +1,7 @@ log_level = "info" mode = "public" db_driver = "Sqlite3" -db_path = "./storage/database/tracker.db" +db_path = "./storage/database/torrust_tracker_e2e_testing.db" announce_interval = 120 min_announce_interval = 120 max_peer_timeout = 900 diff --git a/project-words.txt b/project-words.txt index e6f61e3a..e2e24938 100644 --- a/project-words.txt +++ b/project-words.txt @@ -23,6 +23,7 @@ Leechers LEECHERS lettre luckythelab +mailcatcher nanos NCCA nilm diff --git a/src/mailer.rs b/src/mailer.rs index a8fd4de8..bd8f0383 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -40,13 +40,21 @@ impl MailerService { async fn get_mailer(cfg: &Configuration) -> Mailer { let settings = cfg.settings.read().await; - let creds = Credentials::new(settings.mail.username.to_owned(), settings.mail.password.to_owned()); - - AsyncSmtpTransport::::builder_dangerous(&settings.mail.server) - .port(settings.mail.port) - .credentials(creds) - .authentication(vec![Mechanism::Login, Mechanism::Xoauth2, Mechanism::Plain]) - .build() + if !settings.mail.username.is_empty() && !settings.mail.password.is_empty() { + // SMTP authentication + let creds = Credentials::new(settings.mail.username.clone(), settings.mail.password.clone()); + + AsyncSmtpTransport::::builder_dangerous(&settings.mail.server) + .port(settings.mail.port) + .credentials(creds) + .authentication(vec![Mechanism::Login, Mechanism::Xoauth2, Mechanism::Plain]) + .build() + } else { + // SMTP without authentication + AsyncSmtpTransport::::builder_dangerous(&settings.mail.server) + .port(settings.mail.port) + .build() + } } pub async fn send_verification_mail( diff --git a/src/routes/user.rs b/src/routes/user.rs index df9a385a..8de0f576 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -2,6 +2,7 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder}; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use log::{debug, info}; use pbkdf2::Pbkdf2; use rand_core::OsRng; use serde::{Deserialize, Serialize}; @@ -20,6 +21,7 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) { web::scope("/user") .service(web::resource("/register").route(web::post().to(register))) .service(web::resource("/login").route(web::post().to(login))) + // code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user. .service(web::resource("/ban/{user}").route(web::delete().to(ban_user))) .service(web::resource("/token/verify").route(web::post().to(verify_token))) .service(web::resource("/token/renew").route(web::post().to(renew_token))) @@ -47,6 +49,8 @@ pub struct Token { } pub async fn register(req: HttpRequest, mut payload: web::Json, app_data: WebAppData) -> ServiceResult { + info!("registering user: {}", payload.username); + let settings = app_data.cfg.settings.read().await; match settings.auth.email_on_signup { @@ -253,6 +257,8 @@ pub async fn verify_email(req: HttpRequest, app_data: WebAppData) -> String { // TODO: add reason and date_expiry parameters to request pub async fn ban_user(req: HttpRequest, app_data: WebAppData) -> ServiceResult { + debug!("banning user"); + let user = app_data.auth.get_user_compact_from_request(&req).await?; // check if user is administrator @@ -262,6 +268,8 @@ pub async fn ban_user(req: HttpRequest, app_data: WebAppData) -> ServiceResult Self { Self { - connection_info, - base_path: "/".to_string(), + http_client: Http::new(connection_info), } } - pub async fn root(&self) -> Response { - self.get("", Query::empty()).await - } + // Context: about pub async fn about(&self) -> Response { - self.get("about", Query::empty()).await + self.http_client.get("about", Query::empty()).await } pub async fn license(&self) -> Response { - self.get("about/license", Query::empty()).await + self.http_client.get("about/license", Query::empty()).await } - pub async fn get(&self, path: &str, params: Query) -> Response { - self.get_request_with_query(path, params).await + // Context: category + + pub async fn get_categories(&self) -> Response { + self.http_client.get("category", Query::empty()).await } - /* - pub async fn post(&self, path: &str) -> Response { - let response = reqwest::Client::new().post(self.base_url(path).clone()).send().await.unwrap(); - Response::from(response).await + // Context: root + + pub async fn root(&self) -> Response { + self.http_client.get("", Query::empty()).await } - async fn delete(&self, path: &str) -> Response { - reqwest::Client::new() - .delete(self.base_url(path).clone()) + // Context: user + + pub async fn register_user(&self, registration_form: RegistrationForm) -> Response { + self.http_client.post("user/register", ®istration_form).await + } + + pub async fn login_user(&self, registration_form: LoginForm) -> Response { + self.http_client.post("user/login", ®istration_form).await + } + + pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> Response { + self.http_client.post("user/token/verify", &token_verification_form).await + } + + pub async fn renew_token(&self, token_verification_form: TokenRenewalForm) -> Response { + self.http_client.post("user/token/renew", &token_verification_form).await + } + + pub async fn ban_user(&self, username: Username) -> Response { + self.http_client.delete(&format!("user/ban/{}", &username.value)).await + } +} + +/// Generic HTTP Client +struct Http { + connection_info: ConnectionInfo, + base_path: String, +} + +impl Http { + pub fn new(connection_info: ConnectionInfo) -> Self { + Self { + connection_info, + base_path: "/".to_string(), + } + } + + pub async fn get(&self, path: &str, params: Query) -> Response { + self.get_request_with_query(path, params).await + } + + pub async fn post(&self, path: &str, form: &T) -> Response { + let response = reqwest::Client::new() + .post(self.base_url(path).clone()) + .json(&form) .send() .await - .unwrap() + .unwrap(); + Response::from(response).await } - pub async fn get_request(&self, path: &str) -> Response { - get(&self.base_url(path), None).await + async fn delete(&self, path: &str) -> Response { + let response = match &self.connection_info.token { + Some(token) => reqwest::Client::new() + .delete(self.base_url(path).clone()) + .bearer_auth(token) + .send() + .await + .unwrap(), + None => reqwest::Client::new() + .delete(self.base_url(path).clone()) + .send() + .await + .unwrap(), + }; + Response::from(response).await } - */ pub async fn get_request_with_query(&self, path: &str, params: Query) -> Response { get(&self.base_url(path), Some(params)).await diff --git a/tests/e2e/connection_info.rs b/tests/e2e/connection_info.rs index db019f54..e6c96cf9 100644 --- a/tests/e2e/connection_info.rs +++ b/tests/e2e/connection_info.rs @@ -2,15 +2,28 @@ pub fn anonymous_connection(bind_address: &str) -> ConnectionInfo { ConnectionInfo::anonymous(bind_address) } +pub fn authenticated_connection(bind_address: &str, token: &str) -> ConnectionInfo { + ConnectionInfo::new(bind_address, token) +} + #[derive(Clone)] pub struct ConnectionInfo { pub bind_address: String, + pub token: Option, } impl ConnectionInfo { + pub fn new(bind_address: &str, token: &str) -> Self { + Self { + bind_address: bind_address.to_string(), + token: Some(token.to_string()), + } + } + pub fn anonymous(bind_address: &str) -> Self { Self { bind_address: bind_address.to_string(), + token: None, } } } diff --git a/tests/e2e/routes/about.rs b/tests/e2e/contexts/about.rs similarity index 94% rename from tests/e2e/routes/about.rs rename to tests/e2e/contexts/about.rs index 2274d078..49bf6c41 100644 --- a/tests/e2e/routes/about.rs +++ b/tests/e2e/contexts/about.rs @@ -1,5 +1,5 @@ use crate::e2e::asserts::{assert_response_title, assert_text_ok}; -use crate::e2e::env::TestEnv; +use crate::e2e::environment::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] diff --git a/tests/e2e/routes/category.rs b/tests/e2e/contexts/category.rs similarity index 82% rename from tests/e2e/routes/category.rs rename to tests/e2e/contexts/category.rs index e4254c1f..61c2de7a 100644 --- a/tests/e2e/routes/category.rs +++ b/tests/e2e/contexts/category.rs @@ -1,13 +1,12 @@ use crate::e2e::asserts::assert_json_ok; -use crate::e2e::env::TestEnv; -use crate::e2e::http::Query; +use crate::e2e::environment::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] async fn it_should_return_an_empty_category_list_when_there_are_no_categories() { let client = TestEnv::default().unauthenticated_client(); - let response = client.get("category", Query::empty()).await; + let response = client.get_categories().await; assert_json_ok(&response); } diff --git a/tests/e2e/routes/mod.rs b/tests/e2e/contexts/mod.rs similarity index 77% rename from tests/e2e/routes/mod.rs rename to tests/e2e/contexts/mod.rs index 73340f4e..e96cfc48 100644 --- a/tests/e2e/routes/mod.rs +++ b/tests/e2e/contexts/mod.rs @@ -1,3 +1,4 @@ pub mod about; pub mod category; pub mod root; +pub mod user; diff --git a/tests/e2e/routes/root.rs b/tests/e2e/contexts/root.rs similarity index 90% rename from tests/e2e/routes/root.rs rename to tests/e2e/contexts/root.rs index fa496479..d7ea0e03 100644 --- a/tests/e2e/routes/root.rs +++ b/tests/e2e/contexts/root.rs @@ -1,5 +1,5 @@ use crate::e2e::asserts::{assert_response_title, assert_text_ok}; -use crate::e2e::env::TestEnv; +use crate::e2e::environment::TestEnv; #[tokio::test] #[cfg_attr(not(feature = "e2e-tests"), ignore)] diff --git a/tests/e2e/contexts/user.rs b/tests/e2e/contexts/user.rs new file mode 100644 index 00000000..7fe182e4 --- /dev/null +++ b/tests/e2e/contexts/user.rs @@ -0,0 +1,314 @@ +use serde::{Deserialize, Serialize}; + +use crate::e2e::contexts::user::fixtures::{logged_in_user, random_user_registration, registered_user}; +use crate::e2e::environment::TestEnv; + +/* + +This test suite is not complete. It's just a starting point to show how to +write E2E tests. ANyway, the goal is not to fully cover all the app features +with E2E tests. The goal is to cover the most important features and to +demonstrate how to write E2E tests. Some important pending tests could be: + +todo: + +- It should allow renewing a token one week before it expires. +- It should allow verifying user registration via email. + +The first one requires to mock the time. Consider extracting the mod + into +an independent crate. + +The second one requires: +- To call the mailcatcher API to get the verification URL. +- To enable email verification in the configuration. +- To fix current tests to verify the email for newly created users. +- To find out which email is the one that contains the verification URL for a +given test. That maybe done using the email recipient if that's possible with +the mailcatcher API. + +*/ + +// Request data + +#[derive(Clone, Serialize)] +pub struct RegistrationForm { + pub username: String, + pub email: Option, + pub password: String, + pub confirm_password: String, +} + +type RegisteredUser = RegistrationForm; + +#[derive(Serialize)] +pub struct LoginForm { + pub login: String, + pub password: String, +} + +#[derive(Serialize)] +pub struct TokenVerificationForm { + pub token: String, +} + +#[derive(Serialize)] +pub struct TokenRenewalForm { + pub token: String, +} + +pub struct Username { + pub value: String, +} + +impl Username { + pub fn new(value: String) -> Self { + Self { value } + } +} + +// Responses data + +#[derive(Deserialize)] +pub struct SuccessfulLoginResponse { + pub data: LoggedInUserData, +} + +#[derive(Deserialize, Debug)] +pub struct LoggedInUserData { + pub token: String, + pub username: String, + pub admin: bool, +} + +#[derive(Deserialize)] +pub struct TokenVerifiedResponse { + pub data: String, +} + +#[derive(Deserialize)] +pub struct BannedUserResponse { + pub data: String, +} + +#[derive(Deserialize)] +pub struct TokenRenewalResponse { + pub data: TokenRenewalData, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct TokenRenewalData { + pub token: String, + pub username: String, + pub admin: bool, +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_allow_a_guess_user_to_register() { + let client = TestEnv::default().unauthenticated_client(); + + let form = random_user_registration(); + + let response = client.register_user(form).await; + + assert_eq!(response.body, "", "wrong response body, it should be an empty string"); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "text/plain; charset=utf-8"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_allow_a_registered_user_to_login() { + let client = TestEnv::default().unauthenticated_client(); + + let registered_user = registered_user().await; + + let response = client + .login_user(LoginForm { + login: registered_user.username.clone(), + password: registered_user.password.clone(), + }) + .await; + + let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap(); + let logged_in_user = res.data; + + assert_eq!(logged_in_user.username, registered_user.username); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_allow_a_logged_in_user_to_verify_an_authentication_token() { + let client = TestEnv::default().unauthenticated_client(); + + let logged_in_user = logged_in_user().await; + + let response = client + .verify_token(TokenVerificationForm { + token: logged_in_user.token.clone(), + }) + .await; + + let res: TokenVerifiedResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, "Token is valid."); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +#[tokio::test] +#[cfg_attr(not(feature = "e2e-tests"), ignore)] +async fn it_should_not_allow_a_logged_in_user_to_renew_an_authentication_token_which_is_still_valid_for_more_than_one_week() { + let logged_in_user = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&logged_in_user.token); + + let response = client + .renew_token(TokenRenewalForm { + token: logged_in_user.token.clone(), + }) + .await; + + println!("Response body: {}", response.body); + + let res: TokenRenewalResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!( + res.data, + TokenRenewalData { + token: logged_in_user.token.clone(), // The same token is returned + username: logged_in_user.username.clone(), + admin: logged_in_user.admin, + } + ); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); +} + +mod banned_user_list { + use crate::e2e::contexts::user::fixtures::{logged_in_admin, logged_in_user, registered_user}; + use crate::e2e::contexts::user::{BannedUserResponse, Username}; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_an_admin_to_ban_a_user() { + let logged_in_admin = logged_in_admin().await; + let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + + let registered_user = registered_user().await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + let res: BannedUserResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(res.data, format!("Banned user: {}", registered_user.username)); + if let Some(content_type) = &response.content_type { + assert_eq!(content_type, "application/json"); + } + assert_eq!(response.status, 200); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_a_non_admin_to_ban_a_user() { + let logged_non_admin = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&logged_non_admin.token); + + let registered_user = registered_user().await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + assert_eq!(response.status, 403); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_guess_to_ban_a_user() { + let client = TestEnv::default().unauthenticated_client(); + + let registered_user = registered_user().await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + assert_eq!(response.status, 401); + } +} + +pub mod fixtures { + use std::sync::Arc; + + use rand::Rng; + use torrust_index_backend::databases::database::connect_database; + + use super::{LoggedInUserData, LoginForm, RegisteredUser, RegistrationForm, SuccessfulLoginResponse}; + use crate::e2e::environment::TestEnv; + + pub async fn logged_in_admin() -> LoggedInUserData { + let user = logged_in_user().await; + + // todo: get from E2E config file `config-idx-back.toml.local` + let connect_url = "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc"; + + let database = Arc::new(connect_database(connect_url).await.expect("Database error.")); + + let user_profile = database.get_user_profile_from_username(&user.username).await.unwrap(); + + database.grant_admin_role(user_profile.user_id).await.unwrap(); + + user + } + + pub async fn logged_in_user() -> LoggedInUserData { + let client = TestEnv::default().unauthenticated_client(); + + let registered_user = registered_user().await; + + let response = client + .login_user(LoginForm { + login: registered_user.username.clone(), + password: registered_user.password.clone(), + }) + .await; + + let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap(); + res.data + } + + pub async fn registered_user() -> RegisteredUser { + let client = TestEnv::default().unauthenticated_client(); + + let form = random_user_registration(); + + let registered_user = form.clone(); + + let _response = client.register_user(form).await; + + registered_user + } + + pub fn random_user_registration() -> RegistrationForm { + let user_id = random_user_id(); + RegistrationForm { + username: format!("username_{user_id}"), + email: Some(format!("email_{user_id}@email.com")), + password: "password".to_string(), + confirm_password: "password".to_string(), + } + } + + fn random_user_id() -> u64 { + let mut rng = rand::thread_rng(); + rng.gen_range(0..1_000_000) + } +} diff --git a/tests/e2e/env.rs b/tests/e2e/environment.rs similarity index 62% rename from tests/e2e/env.rs rename to tests/e2e/environment.rs index cfa2b347..f69e2024 100644 --- a/tests/e2e/env.rs +++ b/tests/e2e/environment.rs @@ -1,5 +1,5 @@ +use super::connection_info::{anonymous_connection, authenticated_connection}; use crate::e2e::client::Client; -use crate::e2e::connection_info::anonymous_connection; pub struct TestEnv { pub authority: String, @@ -9,6 +9,10 @@ impl TestEnv { pub fn unauthenticated_client(&self) -> Client { Client::new(anonymous_connection(&self.authority)) } + + pub fn authenticated_client(&self, token: &str) -> Client { + Client::new(authenticated_connection(&self.authority, token)) + } } impl Default for TestEnv { diff --git a/tests/e2e/http.rs b/tests/e2e/http.rs index d682027f..7bfb64ef 100644 --- a/tests/e2e/http.rs +++ b/tests/e2e/http.rs @@ -12,7 +12,7 @@ impl Query { Self { params: vec![] } } - pub fn params(params: Vec) -> Self { + pub fn with_params(params: Vec) -> Self { Self { params } } diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 80151694..1e2c1b02 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -6,7 +6,7 @@ //! cargo test --features e2e-tests //! ``` //! -//! or the Cargo alias +//! or the Cargo alias: //! //! ``` //! cargo e2e @@ -15,11 +15,23 @@ //! > **NOTICE**: E2E tests are not executed by default, because they require //! a running instance of the API. //! +//! You can also run only one test with: +//! +//! ``` +//! cargo test --features e2e-tests TEST_NAME -- --nocapture +//! cargo test --features e2e-tests it_should_register_a_new_user -- --nocapture +//! ``` +//! +//! > **NOTICE**: E2E tests always use the same databases +//! `storage/database/torrust_index_backend_e2e_testing.db` and +//! `./storage/database/torrust_tracker_e2e_testing.db`. If you want to use a +//! clean database, delete the files before running the tests. +//! //! See the docker documentation for more information on how to run the API. mod asserts; mod client; mod connection_info; -pub mod env; +mod contexts; +mod environment; mod http; mod response; -mod routes; diff --git a/tests/e2e/response.rs b/tests/e2e/response.rs index 6861953e..df04680f 100644 --- a/tests/e2e/response.rs +++ b/tests/e2e/response.rs @@ -2,7 +2,7 @@ use reqwest::Response as ReqwestResponse; pub struct Response { pub status: u16, - pub content_type: String, + pub content_type: Option, pub body: String, } @@ -10,7 +10,10 @@ impl Response { pub async fn from(response: ReqwestResponse) -> Self { Self { status: response.status().as_u16(), - content_type: response.headers().get("content-type").unwrap().to_str().unwrap().to_owned(), + content_type: response + .headers() + .get("content-type") + .map(|content_type| content_type.to_str().unwrap().to_owned()), body: response.text().await.unwrap(), } }