From 94fa158705c8f93e8176d07828b37e9e92625461 Mon Sep 17 00:00:00 2001 From: Octavio Simone Date: Mon, 21 Aug 2023 17:26:57 +0200 Subject: [PATCH 1/5] add provisioning apis --- src/auth/mod.rs | 137 +++++- src/constants.rs | 7 + src/groups/models.rs | 3 + src/lib.rs | 72 +++- src/models.rs | 16 + src/nodes/models/mod.rs | 9 + src/nodes/rooms/models.rs | 27 ++ src/provisioning/mod.rs | 280 ++++++++++++ src/provisioning/models.rs | 373 ++++++++++++++++ src/tests/mod.rs | 15 + src/tests/provisioning.rs | 403 ++++++++++++++++++ .../responses/provisioning/customer_ok.json | 27 ++ .../responses/provisioning/customers_ok.json | 36 ++ .../provisioning/new_customer_ok.json | 30 ++ .../provisioning/update_customer_ok.json | 22 + src/tests/users.rs | 4 +- src/user/models.rs | 7 + src/users/models.rs | 24 +- 18 files changed, 1472 insertions(+), 20 deletions(-) create mode 100644 src/provisioning/mod.rs create mode 100644 src/provisioning/models.rs create mode 100644 src/tests/provisioning.rs create mode 100644 src/tests/responses/provisioning/customer_ok.json create mode 100644 src/tests/responses/provisioning/customers_ok.json create mode 100644 src/tests/responses/provisioning/new_customer_ok.json create mode 100644 src/tests/responses/provisioning/update_customer_ok.json diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 1532959..f9346ed 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -4,10 +4,7 @@ use chrono::{DateTime, Utc}; use reqwest::{Client, Url}; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; -use std::{ - marker::PhantomData, - time::Duration, -}; +use std::{marker::PhantomData, time::Duration}; use tracing::{debug, error}; use base64::{ @@ -25,11 +22,11 @@ use crate::{ auth::models::{ OAuth2AuthCodeFlow, OAuth2PasswordFlow, OAuth2TokenResponse, OAuth2TokenRevoke, }, - models::Container, constants::{ DRACOON_TOKEN_REVOKE_URL, DRACOON_TOKEN_URL, EXPONENTIAL_BACKOFF_BASE, MAX_RETRIES, MAX_RETRY_DELAY, MIN_RETRY_DELAY, TOKEN_TYPE_HINT_ACCESS_TOKEN, }, + models::Container, }; use self::{errors::DracoonClientError, models::OAuth2RefreshTokenFlow}; @@ -63,6 +60,10 @@ pub struct Connected; #[derive(Debug, Clone)] pub struct Disconnected; +/// provisioning state of [DracoonClient] +#[derive(Debug, Clone)] +pub struct Provisioning; + /// represents a connection to DRACOON (`OAuth2` tokens) #[derive(Debug, Clone)] pub struct Connection { @@ -114,6 +115,7 @@ pub struct DracoonClient { pub stream_http: Client, connection: Container, connected: PhantomData, + provisioning_token: Option, } /// Builder for the [DracoonClient] struct. @@ -127,6 +129,7 @@ pub struct DracoonClientBuilder { max_retries: Option, min_retry_delay: Option, max_retry_delay: Option, + provisioning_token: Option, } impl DracoonClientBuilder { @@ -141,6 +144,7 @@ impl DracoonClientBuilder { max_retries: None, min_retry_delay: None, max_retry_delay: None, + provisioning_token: None, } } @@ -168,26 +172,93 @@ impl DracoonClientBuilder { self } + /// Sets the user agent (custom string) pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { self.user_agent = Some(user_agent.into()); self } + /// Sets max retries pub fn with_max_retries(mut self, max_retries: u32) -> Self { self.max_retries = Some(max_retries); self } + /// Sets min retry delay pub fn with_min_retry_delay(mut self, min_retry_delay: u64) -> Self { self.min_retry_delay = Some(min_retry_delay); self } + /// Sets max retry delay pub fn with_max_retry_delay(mut self, max_retry_delay: u64) -> Self { self.max_retry_delay = Some(max_retry_delay); self } + pub fn with_provisioning_token(mut self, token: impl Into) -> Self { + self.provisioning_token = Some(token.into()); + self + } + + pub fn build_provisioning(self) -> Result, DracoonClientError> { + let Some(provisioning_token) = self.provisioning_token else { + return Err(DracoonClientError::MissingArgument) + }; + + let max_retries = self + .max_retries + .unwrap_or(MAX_RETRIES) + .clamp(1, MAX_RETRIES); + let min_retry_delay = self + .min_retry_delay + .unwrap_or(MIN_RETRY_DELAY) + .clamp(300, MIN_RETRY_DELAY); + let max_retry_delay = self + .max_retry_delay + .unwrap_or(MAX_RETRY_DELAY) + .clamp(min_retry_delay, MAX_RETRY_DELAY); + + let retry_policy: ExponentialBackoff = ExponentialBackoff::builder() + .backoff_exponent(EXPONENTIAL_BACKOFF_BASE) + .retry_bounds( + Duration::from_millis(min_retry_delay), + Duration::from_millis(max_retry_delay), + ) + .build_with_max_retries(max_retries); + + let user_agent = match self.user_agent { + Some(user_agent) => format!("{}|{}", user_agent, APP_USER_AGENT), + None => APP_USER_AGENT.to_string(), + }; + + let http = Client::builder().user_agent(APP_USER_AGENT).build()?; + let upload_http = http.clone(); + + let http = ClientBuilder::new(http) + .with(RetryTransientMiddleware::new_with_policy(retry_policy)) + .build(); + + let Some(base_url) = self.base_url.clone() else { + error!("Missing base url"); + return Err(DracoonClientError::MissingBaseUrl) + }; + + let base_url = Url::parse(&base_url)?; + + Ok(DracoonClient { + base_url, + redirect_uri: None, + client_id: String::new(), + client_secret: String::new(), + http, + stream_http: upload_http, + connected: PhantomData, + connection: Container::new(), + provisioning_token: Some(provisioning_token), + }) + } + /// Builds the [DracoonClient] struct - returns an error if any of the required fields are missing pub fn build(self) -> Result, DracoonClientError> { let max_retries = self @@ -256,13 +327,18 @@ impl DracoonClientBuilder { connection: Container::::new(), connected: PhantomData, http, - stream_http: upload_http + stream_http: upload_http, + provisioning_token: None, }) } } /// [DracoonClient] implementation for Disconnected state impl DracoonClient { + pub fn builder() -> DracoonClientBuilder { + DracoonClientBuilder::new() + } + /// Connects to DRACOON using any of the supported OAuth2 flows pub async fn connect( self, @@ -291,7 +367,8 @@ impl DracoonClient { redirect_uri: self.redirect_uri, connected: PhantomData, http: self.http, - stream_http: self.stream_http + stream_http: self.stream_http, + provisioning_token: None, }) } @@ -446,6 +523,7 @@ impl DracoonClient { connected: PhantomData, http: self.http, stream_http: self.stream_http, + provisioning_token: None, }) } @@ -568,6 +646,21 @@ impl DracoonClient { } } +impl DracoonClient { + /// Returns the X-SDS-Service-Token for provisioning API calls + pub fn get_service_token(&self) -> String { + self.provisioning_token + .as_ref() + .expect("Provisioning client has no token") + .to_string() + } + + /// Returns the base url of the DRACOON instance + pub fn get_base_url(&self) -> &Url { + &self.base_url + } +} + #[cfg(test)] mod tests { use tokio_test::assert_ok; @@ -659,13 +752,7 @@ mod tests { .unwrap() .refresh_token(); - let expires_in = res - .as_ref() - .unwrap() - .connection - .get() - .unwrap() - .expires_in(); + let expires_in = res.as_ref().unwrap().connection.get().unwrap().expires_in(); assert_eq!(access_token, "access_token"); assert_eq!(refresh_token, "refresh_token"); @@ -835,4 +922,26 @@ mod tests { assert_eq!(header, "Bearer access_token"); } + + #[tokio::test] + async fn test_get_service_token() { + let dracoon = DracoonClient::builder() + .with_base_url("https://test.dracoon.com") + .with_provisioning_token("TopSecret1234!") + .build_provisioning(); + + assert!(dracoon.is_ok()); + let dracoon = dracoon.unwrap(); + + assert_eq!(dracoon.get_service_token(), "TopSecret1234!"); + } + + #[tokio::test] + async fn test_fail_build_with_missing_token() { + let dracoon = DracoonClient::builder() + .with_base_url("https://test.dracoon.com") + .build_provisioning(); + + assert!(dracoon.is_err()); + } } diff --git a/src/constants.rs b/src/constants.rs index b67924f..4a80e1a 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -70,6 +70,13 @@ pub const GROUPS_LAST_ADMIN_ROOMS: &str = "last_admin_rooms"; pub const USERS_BASE: &str = "users"; pub const USERS_LAST_ADMIN_ROOMS: &str = "last_admin_rooms"; +// PROVISIONING +pub const PROVISIONING_BASE: &str = "provisioning"; +pub const PROVISIONING_CUSTOMERS: &str = "customers"; +pub const PROVISIONING_CUSTOMER_ATTRIBUTES: &str = "customerAttributes"; +pub const PROVISIONING_CUSTOMER_USERS: &str = "users"; +pub const PROVISIONING_TOKEN_HEADER: &str = "X-Sds-Service-Token"; + /// user agent header pub const APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "|", env!("CARGO_PKG_VERSION")); diff --git a/src/groups/models.rs b/src/groups/models.rs index 4566505..44d6a7e 100644 --- a/src/groups/models.rs +++ b/src/groups/models.rs @@ -38,6 +38,7 @@ impl FromResponse for GroupList { #[serde(rename_all = "camelCase")] pub struct CreateGroupRequest { name: String, + #[serde(skip_serializing_if = "Option::is_none")] expiration: Option } @@ -53,7 +54,9 @@ impl CreateGroupRequest { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct UpdateGroupRequest { + #[serde(skip_serializing_if = "Option::is_none")] name: Option, + #[serde(skip_serializing_if = "Option::is_none")] expiration: Option } diff --git a/src/lib.rs b/src/lib.rs index 615f9e2..56f597f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ //! * [UploadShares] - for upload share operations //! * [Groups] - for group operations //! * [Users] - for user management operations +//! * [CustomerProvisioning] - for customer provisioning operations //! //! //! ### Example @@ -324,11 +325,37 @@ //! let kp = dracoon.get_keypair(Some(secret)).await.unwrap(); //! # } //! ``` +//! ## Provisioning +//! In order to use the provisioning API to manage customers of a tenant, you can instantiate +//! a client with the `Provisioning` state. +//! All API calls are implemented in the [CustomerProvisioning] trait. +//! +//! ```no_run +//! +//! use dco3::{Dracoon, OAuth2Flow, CustomerProvisioning}; +//! +//! #[tokio::main] +//! async fn main() { +//! // the client only requires passing the base url and a provisioning token +//! // other API calls are *not* supported in this state. +//! let dracoon = Dracoon::builder() +//! .with_base_url("https://dracoon.team") +//! .with_provisioning_token("some_token") +//! .build_provisioning() +//! .unwrap(); +//! +//! // the client is now in the provisioning state and can be used to manage customers +//! let customers = dracoon.get_customers(None).await.unwrap(); +//! +//! } +//! ``` +//! //! ## Examples //! For an example client implementation, see the [dccmd-rs](https://github.com/unbekanntes-pferd/dccmd-rs) repository. use std::marker::PhantomData; +use auth::Provisioning; use dco3_crypto::PlainUserKeyPairContainer; use reqwest::Url; @@ -347,6 +374,7 @@ pub use self::{ groups::Groups, shares::{DownloadShares, UploadShares}, users::Users, + provisioning::CustomerProvisioning, models::*, }; @@ -360,6 +388,7 @@ pub mod utils; pub mod groups; pub mod shares; pub mod users; +pub mod provisioning; mod tests; @@ -393,6 +422,10 @@ impl DracoonBuilder { } } + /// Sets the encryption password - it is *not* permanently stored in the client. + /// The secret will be consumed, once a connection is tried to establish via the `connect` method. + /// The client will then either fail to connect due to wrong encryption secret or permanently store + /// a user's keypair. pub fn with_encryption_password(mut self, encryption_secret: impl Into) -> Self { self.encryption_secret = Some(encryption_secret.into()); self @@ -422,27 +455,37 @@ impl DracoonBuilder { self } + /// Sets a custom user agent prefix for the client pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { self.client_builder = self.client_builder.with_user_agent(user_agent); self } + /// Sets a custom max. retry count (default: 5) pub fn with_max_retries(mut self, max_retries: u32) -> Self { self.client_builder = self.client_builder.with_max_retries(max_retries); self } + /// Sets a custom min. retry delay pub fn with_min_retry_delay(mut self, min_retry_delay: u64) -> Self { self.client_builder = self.client_builder.with_min_retry_delay(min_retry_delay); self } + /// Sets a custom max. retry delay pub fn with_max_retry_delay(mut self, max_retry_delay: u64) -> Self { self.client_builder = self.client_builder.with_max_retry_delay(max_retry_delay); self } - /// Builds the `Dracoon` struct - fails, if any of the required fields are missing + /// Sets X-SDS-Service-token for DRACOON customer provisioning + pub fn with_provisioning_token(mut self, provisioning_token: impl Into) -> Self { + self.client_builder = self.client_builder.with_provisioning_token(provisioning_token); + self + } + + /// Builds the [Dracoon] struct - fails, if any of the required fields are missing pub fn build(self) -> Result, DracoonClientError> { let dracoon = self.client_builder.build()?; @@ -454,6 +497,19 @@ impl DracoonBuilder { encryption_secret: self.encryption_secret, }) } + + /// Builds the [Dracoon] struct set up for provisioning - fails if any of the required fields are missing + pub fn build_provisioning(self) -> Result, DracoonClientError> { + let dracoon = self.client_builder.build_provisioning()?; + + Ok(Dracoon { + client: dracoon, + state: PhantomData, + user_info: Container::new(), + keypair: Container::new(), + encryption_secret: None + }) + } } impl Dracoon { @@ -462,6 +518,7 @@ impl Dracoon { DracoonBuilder::new() } + pub async fn connect( self, oauth_flow: OAuth2Flow, @@ -542,3 +599,16 @@ impl Dracoon { } } +impl Dracoon { + pub fn get_service_token(&self) -> String { + self.client.get_service_token() + } + + pub fn build_api_url(&self, url_part: &str) -> Url { + self.client + .get_base_url() + .join(url_part) + .expect("Correct base url") + } +} + diff --git a/src/models.rs b/src/models.rs index 04862aa..ae0fe23 100644 --- a/src/models.rs +++ b/src/models.rs @@ -249,10 +249,18 @@ impl IntoIterator for RangedItems { pub trait FilterQuery: Debug + Send + Sync { fn to_filter_string(&self) -> String; + + fn builder() -> FilterQueryBuilder where Self: Sized { + FilterQueryBuilder::new() + } } pub trait SortQuery: Debug + Send + Sync { fn to_sort_string(&self) -> String; + + fn builder() -> SortQueryBuilder where Self: Sized { + SortQueryBuilder::new() + } } pub type FilterQueries = Vec>; @@ -315,6 +323,8 @@ impl From<&SortOrder> for String { } } + + #[derive(Default)] pub struct FilterQueryBuilder { field: Option, @@ -419,6 +429,12 @@ impl From for Box { } } +#[derive(Debug, Serialize, Deserialize)] +pub struct KeyValueEntry { + pub key: String, + pub value: String, +} + #[cfg(test)] mod tests { diff --git a/src/nodes/models/mod.rs b/src/nodes/models/mod.rs index b5db0c9..d144a8f 100644 --- a/src/nodes/models/mod.rs +++ b/src/nodes/models/mod.rs @@ -882,9 +882,13 @@ impl TransferNode { pub struct CreateFolderRequest { name: String, parent_id: u64, + #[serde(skip_serializing_if = "Option::is_none")] notes: Option, + #[serde(skip_serializing_if = "Option::is_none")] timestamp_creation: Option, + #[serde(skip_serializing_if = "Option::is_none")] timestamp_modification: Option, + #[serde(skip_serializing_if = "Option::is_none")] classification: Option, } @@ -946,10 +950,15 @@ impl CreateFolderRequestBuilder { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct UpdateFolderRequest { + #[serde(skip_serializing_if = "Option::is_none")] name: Option, + #[serde(skip_serializing_if = "Option::is_none")] notes: Option, + #[serde(skip_serializing_if = "Option::is_none")] timestamp_creation: Option, + #[serde(skip_serializing_if = "Option::is_none")] timestamp_modification: Option, + #[serde(skip_serializing_if = "Option::is_none")] classification: Option, } diff --git a/src/nodes/rooms/models.rs b/src/nodes/rooms/models.rs index eb0d0ac..4afe098 100644 --- a/src/nodes/rooms/models.rs +++ b/src/nodes/rooms/models.rs @@ -17,17 +17,29 @@ use crate::{ #[serde(rename_all = "camelCase")] pub struct CreateRoomRequest { name: String, + #[serde(skip_serializing_if = "Option::is_none")] parent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] recycle_bin_retention_period: Option, + #[serde(skip_serializing_if = "Option::is_none")] quota: Option, + #[serde(skip_serializing_if = "Option::is_none")] inherit_permissions: Option, + #[serde(skip_serializing_if = "Option::is_none")] admin_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] admin_group_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] new_group_member_acceptance: Option, + #[serde(skip_serializing_if = "Option::is_none")] notes: Option, + #[serde(skip_serializing_if = "Option::is_none")] has_activities_log: Option, + #[serde(skip_serializing_if = "Option::is_none")] classification: Option, + #[serde(skip_serializing_if = "Option::is_none")] timestamp_creation: Option, + #[serde(skip_serializing_if = "Option::is_none")] timestamp_modification: Option, } @@ -161,10 +173,15 @@ impl CreateRoomRequestBuilder { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct UpdateRoomRequest { + #[serde(skip_serializing_if = "Option::is_none")] name: Option, + #[serde(skip_serializing_if = "Option::is_none")] quota: Option, + #[serde(skip_serializing_if = "Option::is_none")] notes: Option, + #[serde(skip_serializing_if = "Option::is_none")] timestamp_creation: Option, + #[serde(skip_serializing_if = "Option::is_none")] timestamp_modification: Option, } @@ -231,13 +248,21 @@ impl UpdateRoomRequestBuilder { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ConfigRoomRequest { + #[serde(skip_serializing_if = "Option::is_none")] recycle_bin_retention_period: Option, + #[serde(skip_serializing_if = "Option::is_none")] inherit_permissions: Option, + #[serde(skip_serializing_if = "Option::is_none")] take_over_permissions: Option, + #[serde(skip_serializing_if = "Option::is_none")] admin_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] admin_group_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] new_group_member_acceptance: Option, + #[serde(skip_serializing_if = "Option::is_none")] has_activities_log: Option, + #[serde(skip_serializing_if = "Option::is_none")] classification: Option, } @@ -329,7 +354,9 @@ impl ConfigRoomRequestBuilder { #[serde(rename_all = "camelCase")] pub struct EncryptRoomRequest { is_encrypted: bool, + #[serde(skip_serializing_if = "Option::is_none")] use_data_space_rescue_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] data_room_rescue_key: Option, } diff --git a/src/provisioning/mod.rs b/src/provisioning/mod.rs new file mode 100644 index 0000000..b13dc81 --- /dev/null +++ b/src/provisioning/mod.rs @@ -0,0 +1,280 @@ +use async_trait::async_trait; + +mod models; + +pub use self::models::*; + +use crate::{ + auth::Provisioning, + constants::{ + DRACOON_API_PREFIX, PROVISIONING_BASE, PROVISIONING_CUSTOMERS, + PROVISIONING_CUSTOMER_ATTRIBUTES, PROVISIONING_CUSTOMER_USERS, PROVISIONING_TOKEN_HEADER, + }, + users::UserList, + utils::FromResponse, + Dracoon, DracoonClientError, ListAllParams, +}; + +#[async_trait] +pub trait CustomerProvisioning { + async fn get_customers( + &self, + params: Option, + ) -> Result; + async fn create_customer( + &self, + req: NewCustomerRequest, + ) -> Result; + async fn get_customer( + &self, + id: u64, + include_attributes: Option, + ) -> Result; + async fn update_customer( + &self, + id: u64, + req: UpdateCustomerRequest, + ) -> Result; + async fn delete_customer(&self, id: u64) -> Result<(), DracoonClientError>; + async fn get_customer_users(&self, id: u64, params: Option) -> Result; + async fn get_customer_attributes( + &self, + id: u64, + params: Option, + ) -> Result; + async fn update_customer_attributes( + &self, + id: u64, + req: CustomerAttributes, + ) -> Result; + async fn delete_customer_attribute( + &self, + id: u64, + key: String, + ) -> Result<(), DracoonClientError>; +} + +#[async_trait] +impl CustomerProvisioning for Dracoon { + async fn get_customers( + &self, + params: Option, + ) -> Result { + let params = params.unwrap_or_default(); + let url_part = format!("{DRACOON_API_PREFIX}/{PROVISIONING_BASE}/{PROVISIONING_CUSTOMERS}"); + + let mut api_url = self.build_api_url(&url_part); + + let filters = params.filter_to_string(); + let sorts = params.sort_to_string(); + + api_url + .query_pairs_mut() + .extend_pairs(params.limit.map(|v| ("limit", v.to_string()))) + .extend_pairs(params.offset.map(|v| ("offset", v.to_string()))) + .extend_pairs(params.sort.map(|_| ("sort", sorts))) + .extend_pairs(params.filter.map(|_| ("filter", filters))) + .finish(); + + let response = self + .client + .http + .get(api_url) + .header(PROVISIONING_TOKEN_HEADER, self.get_service_token()) + .send() + .await?; + + CustomerList::from_response(response).await + } + async fn create_customer( + &self, + req: NewCustomerRequest, + ) -> Result { + let url_part = format!("{DRACOON_API_PREFIX}/{PROVISIONING_BASE}/{PROVISIONING_CUSTOMERS}"); + let api_url = self.build_api_url(&url_part); + + let response = self + .client + .http + .post(api_url) + .header(PROVISIONING_TOKEN_HEADER, self.get_service_token()) + .json(&req) + .send() + .await?; + + NewCustomerResponse::from_response(response).await + } + async fn get_customer( + &self, + id: u64, + include_attributes: Option, + ) -> Result { + let url_part = + format!("{DRACOON_API_PREFIX}/{PROVISIONING_BASE}/{PROVISIONING_CUSTOMERS}/{id}"); + + let mut api_url = self.build_api_url(&url_part); + + if include_attributes.is_some() { + api_url + .query_pairs_mut() + .extend_pairs(include_attributes.map(|v| ("include_attributes", v.to_string()))) + .finish(); + } + + let response = self + .client + .http + .get(api_url) + .header(PROVISIONING_TOKEN_HEADER, self.get_service_token()) + .send() + .await?; + + Customer::from_response(response).await + } + async fn update_customer( + &self, + id: u64, + req: UpdateCustomerRequest, + ) -> Result { + let url_part = + format!("{DRACOON_API_PREFIX}/{PROVISIONING_BASE}/{PROVISIONING_CUSTOMERS}/{id}"); + let api_url = self.build_api_url(&url_part); + + let response = self + .client + .http + .put(api_url) + .header(PROVISIONING_TOKEN_HEADER, self.get_service_token()) + .json(&req) + .send() + .await?; + + UpdateCustomerResponse::from_response(response).await + } + + async fn delete_customer(&self, id: u64) -> Result<(), DracoonClientError> { + let url_part = + format!("{DRACOON_API_PREFIX}/{PROVISIONING_BASE}/{PROVISIONING_CUSTOMERS}/{id}"); + + let api_url = self.build_api_url(&url_part); + + let response = self + .client + .http + .delete(api_url) + .header(PROVISIONING_TOKEN_HEADER, self.get_service_token()) + .send() + .await?; + + if response.status().is_server_error() || response.status().is_client_error() { + return Err(DracoonClientError::from_response(response) + .await + .expect("Could not parse error response")); + } + + Ok(()) + } + async fn get_customer_users(&self, id: u64, params: Option) -> Result { + let params = params.unwrap_or_default(); + let url_part = format!("{DRACOON_API_PREFIX}/{PROVISIONING_BASE}/{PROVISIONING_CUSTOMERS}/{id}/{PROVISIONING_CUSTOMER_USERS}"); + + let mut api_url = self.build_api_url(&url_part); + + let filters = params.filter_to_string(); + let sorts = params.sort_to_string(); + + api_url + .query_pairs_mut() + .extend_pairs(params.limit.map(|v| ("limit", v.to_string()))) + .extend_pairs(params.offset.map(|v| ("offset", v.to_string()))) + .extend_pairs(params.sort.map(|_| ("sort", sorts))) + .extend_pairs(params.filter.map(|_| ("filter", filters))) + .finish(); + + let response = self + .client + .http + .get(api_url) + .header(PROVISIONING_TOKEN_HEADER, self.get_service_token()) + .send() + .await?; + + UserList::from_response(response).await + } + async fn get_customer_attributes( + &self, + id: u64, + params: Option, + ) -> Result { + let params = params.unwrap_or_default(); + let url_part = format!("{DRACOON_API_PREFIX}/{PROVISIONING_BASE}/{PROVISIONING_CUSTOMERS}/{id}/{PROVISIONING_CUSTOMER_ATTRIBUTES}"); + + let mut api_url = self.build_api_url(&url_part); + + let filters = params.filter_to_string(); + let sorts = params.sort_to_string(); + + api_url + .query_pairs_mut() + .extend_pairs(params.limit.map(|v| ("limit", v.to_string()))) + .extend_pairs(params.offset.map(|v| ("offset", v.to_string()))) + .extend_pairs(params.sort.map(|_| ("sort", sorts))) + .extend_pairs(params.filter.map(|_| ("filter", filters))) + .finish(); + + let response = self + .client + .http + .get(api_url) + .header(PROVISIONING_TOKEN_HEADER, self.get_service_token()) + .send() + .await?; + + AttributesResponse::from_response(response).await + } + async fn update_customer_attributes( + &self, + id: u64, + req: CustomerAttributes, + ) -> Result { + let url_part = format!("{DRACOON_API_PREFIX}/{PROVISIONING_BASE}/{PROVISIONING_CUSTOMERS}/{id}/{PROVISIONING_CUSTOMER_ATTRIBUTES}"); + + let api_url = self.build_api_url(&url_part); + + let response = self + .client + .http + .put(api_url) + .header(PROVISIONING_TOKEN_HEADER, self.get_service_token()) + .json(&req) + .send() + .await?; + + Customer::from_response(response).await + } + async fn delete_customer_attribute( + &self, + id: u64, + key: String, + ) -> Result<(), DracoonClientError> { + let url_part = format!("{DRACOON_API_PREFIX}/{PROVISIONING_BASE}/{PROVISIONING_CUSTOMERS}/{id}/{PROVISIONING_CUSTOMER_ATTRIBUTES}/{key}"); + + let api_url = self.build_api_url(&url_part); + + let response = self + .client + .http + .delete(api_url) + .header(PROVISIONING_TOKEN_HEADER, self.get_service_token()) + .send() + .await?; + + if response.status().is_server_error() || response.status().is_client_error() { + return Err(DracoonClientError::from_response(response) + .await + .expect("Could not parse error response")); + } + + Ok(()) + } +} diff --git a/src/provisioning/models.rs b/src/provisioning/models.rs new file mode 100644 index 0000000..e3e2f1a --- /dev/null +++ b/src/provisioning/models.rs @@ -0,0 +1,373 @@ +use async_trait::async_trait; +use reqwest::Response; +use serde::{Deserialize, Serialize}; + +use crate::{user::UserAuthData, KeyValueEntry, RangedItems, utils::{FromResponse, parse_body}, DracoonClientError, auth::DracoonErrorResponse}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct CustomerAttributes { + pub items: Vec, +} + +impl CustomerAttributes { + pub fn new() -> CustomerAttributes { + CustomerAttributes::default() + } + + pub fn add_attribute(&mut self, key: String, value: String) { + let attrib = KeyValueEntry { key, value }; + self.items.push(attrib); + } +} + +pub type AttributesResponse = RangedItems; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Customer { + pub id: u64, + pub company_name: String, + pub customer_contract_type: String, + pub quota_max: u64, + pub quota_used: u64, + pub user_max: u64, + pub user_used: u64, + pub created_at: String, + pub customer_attributes: Option, + pub updated_at: Option, + pub last_login_at: Option, + pub trial_days_left: Option, + pub is_locked: Option, + pub customer_uuid: Option, + pub cnt_internal_user: Option, + pub cnt_guest_user: Option, +} + +pub type CustomerList = RangedItems; + +#[async_trait] +impl FromResponse for CustomerList { + async fn from_response(response: Response) -> Result { + parse_body::(response).await + } +} + + +#[async_trait] +impl FromResponse for Customer { + async fn from_response(response: Response) -> Result { + parse_body::(response).await + } +} + +#[async_trait] +impl FromResponse for NewCustomerResponse { + async fn from_response(response: Response) -> Result { + parse_body::(response).await + } +} + +#[async_trait] +impl FromResponse for UpdateCustomerResponse { + async fn from_response(response: Response) -> Result { + parse_body::(response).await + } +} + +#[async_trait] +impl FromResponse for AttributesResponse { + async fn from_response(response: Response) -> Result { + parse_body::(response).await + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FirstAdminUser { + pub first_name: String, + pub last_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub receiver_language: Option, + pub notify_user: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone: Option, +} + +impl FirstAdminUser { + pub fn new_local( + first_name: impl Into, + last_name: impl Into, + user_name: Option, + email: impl Into, + receiver_language: Option, + ) -> FirstAdminUser { + let auth_data = UserAuthData::new_basic(None); + + FirstAdminUser { + first_name: first_name.into(), + last_name: last_name.into(), + user_name, + auth_data: Some(auth_data), + receiver_language, + notify_user: None, + email: Some(email.into()), + phone: None, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NewCustomerRequest { + pub customer_contract_type: String, + pub quota_max: u64, + pub user_max: u64, + pub first_admin_user: FirstAdminUser, + #[serde(skip_serializing_if = "Option::is_none")] + pub company_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub trial_days: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_locked: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub customer_attributes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_customer_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub webhooks_max: Option, +} + +impl NewCustomerRequest { + pub fn builder( + customer_contract_type: impl Into, + quota_max: u64, + user_max: u64, + first_admin_user: FirstAdminUser, + ) -> NewCustomerRequestBuilder { + NewCustomerRequestBuilder::new( + customer_contract_type.into(), + quota_max, + user_max, + first_admin_user, + ) + } +} + +pub struct NewCustomerRequestBuilder { + customer_contract_type: String, + quota_max: u64, + user_max: u64, + first_admin_user: FirstAdminUser, + company_name: Option, + trial_days: Option, + is_locked: Option, + customer_attributes: Option, + provider_customer_id: Option, + webhooks_max: Option, +} + +impl NewCustomerRequestBuilder { + pub fn new( + customer_contract_type: String, + quota_max: u64, + user_max: u64, + first_admin_user: FirstAdminUser, + ) -> NewCustomerRequestBuilder { + NewCustomerRequestBuilder { + customer_contract_type, + quota_max, + user_max, + first_admin_user, + company_name: None, + trial_days: None, + is_locked: None, + customer_attributes: None, + provider_customer_id: None, + webhooks_max: None, + } + } + + pub fn with_company_name( + mut self, + company_name: impl Into, + ) -> NewCustomerRequestBuilder { + self.company_name = Some(company_name.into()); + self + } + + pub fn with_trial_days(mut self, trial_days: u64) -> NewCustomerRequestBuilder { + self.trial_days = Some(trial_days); + self + } + + pub fn with_is_locked(mut self, is_locked: bool) -> NewCustomerRequestBuilder { + self.is_locked = Some(is_locked); + self + } + + pub fn with_customer_attributes( + mut self, + customer_attributes: CustomerAttributes, + ) -> NewCustomerRequestBuilder { + self.customer_attributes = Some(customer_attributes); + self + } + + pub fn with_provider_customer_id( + mut self, + provider_customer_id: String, + ) -> NewCustomerRequestBuilder { + self.provider_customer_id = Some(provider_customer_id); + self + } + + pub fn with_webhooks_max(mut self, webhooks_max: u64) -> NewCustomerRequestBuilder { + self.webhooks_max = Some(webhooks_max); + self + } + + pub fn build(self) -> NewCustomerRequest { + NewCustomerRequest { + customer_contract_type: self.customer_contract_type, + quota_max: self.quota_max, + user_max: self.user_max, + first_admin_user: self.first_admin_user, + company_name: self.company_name, + trial_days: self.trial_days, + is_locked: self.is_locked, + customer_attributes: self.customer_attributes, + provider_customer_id: self.provider_customer_id, + webhooks_max: self.webhooks_max, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NewCustomerResponse { + pub id: u64, + pub company_name: String, + pub customer_contract_type: String, + pub quota_max: u64, + pub user_max: u64, + pub is_locked: Option, + pub trial_days: Option, + pub created_at: Option, + pub first_admin_user: FirstAdminUser, + pub customer_attributes: Option, + pub provider_customer_id: Option, + pub webhooks_max: Option +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateCustomerResponse { + pub id: u64, + pub company_name: String, + pub customer_contract_type: String, + pub quota_max: u64, + pub user_max: u64, + pub customer_uuid: String, + pub is_locked: Option, + pub trial_days: Option, + pub created_at: Option, + pub updated_at: Option, + pub customer_attributes: Option, + pub provider_customer_id: Option, + pub webhooks_max: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateCustomerRequest { + #[serde(skip_serializing_if = "Option::is_none")] + company_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + customer_contract_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + quota_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + is_locked: Option, + #[serde(skip_serializing_if = "Option::is_none")] + provider_customer_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + webhooks_max: Option, +} + +impl UpdateCustomerRequest { + pub fn builder() -> UpdateCustomerRequestBuilder { + UpdateCustomerRequestBuilder::new() + } +} + +#[derive(Debug, Default)] +pub struct UpdateCustomerRequestBuilder { + company_name: Option, + customer_contract_type: Option, + quota_max: Option, + user_max: Option, + is_locked: Option, + provider_customer_id: Option, + webhooks_max: Option, +} + +impl UpdateCustomerRequestBuilder { + pub fn new() -> Self { + UpdateCustomerRequestBuilder::default() + } + + pub fn with_company_name(mut self, company_name: impl Into) -> Self { + self.company_name = Some(company_name.into()); + self + } + + pub fn with_customer_contract_type(mut self, customer_contract_type: impl Into) -> Self { + self.customer_contract_type = Some(customer_contract_type.into()); + self + } + + pub fn with_quota_max(mut self, quota_max: u64) -> Self { + self.quota_max = Some(quota_max); + self + } + + pub fn with_user_max(mut self, user_max: u64) -> Self { + self.user_max = Some(user_max); + self + } + + pub fn with_is_locked(mut self, is_locked: bool) -> Self { + self.is_locked = Some(is_locked); + self + } + + pub fn with_provider_customer_id(mut self, provider_customer_id: u64) -> Self { + self.provider_customer_id = Some(provider_customer_id); + self + } + + pub fn with_webhooks_max(mut self, webhooks_max: u64) -> Self { + self.webhooks_max = Some(webhooks_max); + self + } + + pub fn build(self) -> UpdateCustomerRequest { + UpdateCustomerRequest { + company_name: self.company_name, + customer_contract_type: self.customer_contract_type, + quota_max: self.quota_max, + user_max: self.user_max, + is_locked: self.is_locked, + provider_customer_id: self.provider_customer_id, + webhooks_max: self.webhooks_max, + } + } +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index b0a17db..b32bfc9 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -4,6 +4,7 @@ mod rooms; mod shares; mod users; mod user; +mod provisioning; #[cfg(test)] @@ -143,4 +144,18 @@ pub mod dracoon { assert_user_account(&user_info); } + + #[tokio::test] + async fn test_get_provisioning_token() { + let client = Dracoon::builder() + .with_base_url("https://dracoon.team") + .with_provisioning_token("token") + .build_provisioning() + .unwrap(); + + let token = client.get_service_token(); + + assert_eq!(token, "token"); + + } } \ No newline at end of file diff --git a/src/tests/provisioning.rs b/src/tests/provisioning.rs new file mode 100644 index 0000000..2300ab6 --- /dev/null +++ b/src/tests/provisioning.rs @@ -0,0 +1,403 @@ +#[cfg(test)] +mod tests { + use crate::{ + auth::Provisioning, + provisioning::{Customer, FirstAdminUser, NewCustomerRequest, UpdateCustomerRequest}, + CustomerProvisioning, Dracoon, ListAllParams, tests::users::tests::assert_user_item, + }; + + async fn get_provisioning_client() -> (Dracoon, mockito::ServerGuard) { + let mock_server = mockito::Server::new(); + let base_url = mock_server.url(); + + let dracoon = Dracoon::builder() + .with_base_url(base_url) + .with_provisioning_token("token") + .build_provisioning() + .unwrap(); + + (dracoon, mock_server) + } + + async fn assert_customer(customer: &Customer) { + assert_eq!(customer.id, 1); + assert_eq!(customer.company_name, "string"); + assert_eq!(customer.customer_contract_type, "pay"); + assert_eq!(customer.quota_max, 10000000); + assert_eq!(customer.quota_used, 10); + assert_eq!(customer.user_max, 100); + assert_eq!(customer.user_used, 100); + assert_eq!(customer.cnt_guest_user.unwrap(), 1); + assert_eq!(customer.cnt_internal_user.unwrap(), 99); + assert_eq!(customer.created_at, "2020-01-00T00:00:00.000Z"); + assert_eq!( + customer.updated_at.as_ref().unwrap(), + "2020-01-00T00:00:00.000Z" + ); + assert_eq!(customer.trial_days_left.unwrap(), 0); + assert_eq!(customer.customer_uuid.as_ref().unwrap(), "string"); + assert!(customer.customer_attributes.is_some()); + assert!(customer.customer_attributes.as_ref().unwrap().items.len() > 0); + assert!(customer + .customer_attributes + .as_ref() + .unwrap() + .items + .get(0) + .is_some()); + let kv = customer + .customer_attributes + .as_ref() + .unwrap() + .items + .get(0) + .unwrap(); + assert_eq!(kv.key, "string"); + assert_eq!(kv.value, "string"); + assert!(!customer.is_locked.unwrap()); + } + + #[tokio::test] + async fn test_get_customers() { + let (dracoon, mut mock_server) = get_provisioning_client().await; + let customers_res = include_str!("./responses/provisioning/customers_ok.json"); + + let customers_mock = mock_server + .mock("GET", "/api/v4/provisioning/customers?offset=0") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(customers_res) + .create(); + + let customers = dracoon.get_customers(None).await.unwrap(); + assert_eq!(customers.range.total, 1); + assert_eq!(customers.range.offset, 0); + assert_eq!(customers.range.limit, 0); + assert_eq!(customers.items.len(), 1); + + let customer = customers.items.get(0).unwrap(); + assert_customer(customer).await; + + customers_mock.assert(); + } + + #[tokio::test] + async fn test_get_customers_with_limit() { + let (dracoon, mut mock_server) = get_provisioning_client().await; + let customers_res = include_str!("./responses/provisioning/customers_ok.json"); + + let customers_mock = mock_server + .mock("GET", "/api/v4/provisioning/customers?limit=100&offset=0") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(customers_res) + .create(); + + let params = ListAllParams::builder().with_limit(100).build(); + + let customers = dracoon.get_customers(Some(params)).await.unwrap(); + assert_eq!(customers.range.total, 1); + assert_eq!(customers.range.offset, 0); + assert_eq!(customers.range.limit, 0); + assert_eq!(customers.items.len(), 1); + + let customer = customers.items.get(0).unwrap(); + assert_customer(customer).await; + + customers_mock.assert(); + } + + #[tokio::test] + async fn test_get_customers_with_offset() { + let (dracoon, mut mock_server) = get_provisioning_client().await; + let customers_res = include_str!("./responses/provisioning/customers_ok.json"); + + let customers_mock = mock_server + .mock("GET", "/api/v4/provisioning/customers?offset=500") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(customers_res) + .create(); + + let params = ListAllParams::builder().with_offset(500).build(); + + let customers = dracoon.get_customers(Some(params)).await.unwrap(); + assert_eq!(customers.range.total, 1); + assert_eq!(customers.range.offset, 0); + assert_eq!(customers.range.limit, 0); + assert_eq!(customers.items.len(), 1); + + let customer = customers.items.get(0).unwrap(); + assert_customer(customer).await; + + customers_mock.assert(); + } + + #[tokio::test] + #[ignore = "missing models for filter query"] + async fn test_get_customers_with_filter() { + let (dracoon, mut mock_server) = get_provisioning_client().await; + let customers_res = include_str!("./responses/provisioning/customers_ok.json"); + + let customers_mock = mock_server + // TODO: add filter query + .mock("GET", "/api/v4/provisioning/customers?offset=0") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(customers_res) + .create(); + + // TODO: add filter query + let params = ListAllParams::builder().build(); + + let customers = dracoon.get_customers(Some(params)).await.unwrap(); + assert_eq!(customers.range.total, 1); + assert_eq!(customers.range.offset, 0); + assert_eq!(customers.range.limit, 0); + assert_eq!(customers.items.len(), 1); + + let customer = customers.items.get(0).unwrap(); + assert_customer(customer).await; + + customers_mock.assert(); + } + + #[tokio::test] + #[ignore = "missing models for sort query"] + async fn test_get_customers_with_sort() { + let (dracoon, mut mock_server) = get_provisioning_client().await; + let customers_res = include_str!("./responses/provisioning/customers_ok.json"); + + let customers_mock = mock_server + // TODO: add sort query + .mock("GET", "/api/v4/provisioning/customers?offset=0") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(customers_res) + .create(); + + // TODO: add sort query + let customers = dracoon.get_customers(None).await.unwrap(); + assert_eq!(customers.range.total, 1); + assert_eq!(customers.range.offset, 0); + assert_eq!(customers.range.limit, 0); + assert_eq!(customers.items.len(), 1); + + let customer = customers.items.get(0).unwrap(); + assert_customer(customer).await; + + customers_mock.assert(); + } + + #[tokio::test] + async fn test_create_customer() { + let (dracoon, mut mock_server) = get_provisioning_client().await; + + let res = include_str!("./responses/provisioning/new_customer_ok.json"); + + let customer_mock = mock_server + .mock("POST", "/api/v4/provisioning/customers") + .with_status(201) + .with_header("content-type", "application/json") + .with_body(res) + .create(); + + let user = FirstAdminUser::new_local("test", "test", None, "test@localhost", None); + + let customer = NewCustomerRequest::builder("pay", 100000000, 100, user) + .with_company_name("test") + .build(); + + let customer = dracoon.create_customer(customer).await.unwrap(); + + assert_eq!(customer.id, 1); + assert_eq!(customer.company_name, "string"); + assert_eq!(customer.quota_max, 10000000); + assert_eq!(customer.user_max, 100); + assert_eq!(customer.customer_contract_type, "pay"); + assert_eq!(customer.first_admin_user.first_name, "string"); + assert_eq!(customer.first_admin_user.last_name, "string"); + assert_eq!( + customer.first_admin_user.user_name.as_ref().unwrap(), + "string" + ); + assert_eq!(customer.first_admin_user.email.as_ref().unwrap(), "string"); + assert_eq!( + customer.first_admin_user.auth_data.as_ref().unwrap().method, + "basic" + ); + assert!(customer.first_admin_user.notify_user.as_ref().unwrap()); + assert_eq!(customer.first_admin_user.phone.as_ref().unwrap(), "string"); + assert_eq!(customer.trial_days.as_ref().unwrap(), &0); + assert_eq!(customer.provider_customer_id.as_ref().unwrap(), "string"); + assert_eq!(customer.webhooks_max.as_ref().unwrap(), &1); + + assert!(customer.customer_attributes.is_some()); + assert_eq!( + customer.customer_attributes.as_ref().unwrap().items.len(), + 1 + ); + let kv = customer + .customer_attributes + .as_ref() + .unwrap() + .items + .get(0) + .unwrap(); + + assert_eq!(kv.key, "string"); + assert_eq!(kv.value, "string"); + + customer_mock.assert(); + } + + #[tokio::test] + async fn test_get_customer() { + let (dracoon, mut mock_server) = get_provisioning_client().await; + let res = include_str!("./responses/provisioning/customer_ok.json"); + + let customer_mock = mock_server + .mock("GET", "/api/v4/provisioning/customers/1") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(res) + .create(); + + let customer = dracoon.get_customer(1, None).await.unwrap(); + + assert_customer(&customer).await; + + customer_mock.assert(); + } + + #[tokio::test] + async fn test_get_customer_including_attributes() { + let (dracoon, mut mock_server) = get_provisioning_client().await; + let res = include_str!("./responses/provisioning/customer_ok.json"); + + let customer_mock = mock_server + .mock("GET", "/api/v4/provisioning/customers/1?include_attributes=true") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(res) + .create(); + + let customer = dracoon.get_customer(1, Some(true)).await.unwrap(); + + assert_customer(&customer).await; + + customer_mock.assert(); + } + + #[tokio::test] + async fn test_update_customer() { + let (dracoon, mut mock_server) = get_provisioning_client().await; + let res = include_str!("./responses/provisioning/update_customer_ok.json"); + + let customer_mock = mock_server + .mock("PUT", "/api/v4/provisioning/customers/1") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(res) + .create(); + + let customer = UpdateCustomerRequest::builder() + .with_quota_max(1000000000) + .build(); + + let customer = dracoon.update_customer(1, customer).await.unwrap(); + + assert_eq!(customer.id, 1); + assert_eq!(customer.company_name, "string"); + assert_eq!(customer.quota_max, 10000000); + assert_eq!(customer.user_max, 100); + assert_eq!(customer.customer_contract_type, "pay"); + assert_eq!(customer.trial_days.as_ref().unwrap(), &0); + assert_eq!(customer.provider_customer_id.as_ref().unwrap(), "string"); + assert_eq!(customer.webhooks_max.as_ref().unwrap(), &1); + assert_eq!(customer.customer_uuid, "string"); + + customer_mock.assert(); + } + + #[tokio::test] + async fn test_delete_customer() { + let (dracoon, mut mock_server) = get_provisioning_client().await; + + let del_mock = mock_server + .mock("DELETE", "/api/v4/provisioning/customers/1") + .with_status(204) + .create(); + + let res = dracoon.delete_customer(1).await; + assert!(res.is_ok()); + + } + + #[tokio::test] + async fn test_get_customer_users() { + let (dracoon, mut mock_server) = get_provisioning_client().await; + let res = include_str!("./responses/users/users_ok.json"); + + let customer_mock = mock_server + .mock("GET", "/api/v4/provisioning/customers/1/users?offset=0") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(res) + .create(); + + let users = dracoon.get_customer_users(1, None).await.unwrap(); + + assert_eq!(users.items.len(), 1); + + let user = users.items.get(0).unwrap(); + + assert_user_item(user); + } + + #[tokio::test] + async fn test_get_customer_users_with_limit() { + let (dracoon, mut mock_server) = get_provisioning_client().await; + let res = include_str!("./responses/users/users_ok.json"); + + let customer_mock = mock_server + .mock("GET", "/api/v4/provisioning/customers/1/users?limit=100&offset=0") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(res) + .create(); + + let params = ListAllParams::builder().with_limit(100).build(); + + let users = dracoon.get_customer_users(1, Some(params)).await.unwrap(); + + assert_eq!(users.items.len(), 1); + + let user = users.items.get(0).unwrap(); + + assert_user_item(user); + } + + #[tokio::test] + async fn test_get_customer_users_with_offset() { + let (dracoon, mut mock_server) = get_provisioning_client().await; + let res = include_str!("./responses/users/users_ok.json"); + + let customer_mock = mock_server + .mock("GET", "/api/v4/provisioning/customers/1/users?offset=500") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(res) + .create(); + + let params = ListAllParams::builder().with_offset(500).build(); + + let users = dracoon.get_customer_users(1, Some(params)).await.unwrap(); + + assert_eq!(users.items.len(), 1); + + let user = users.items.get(0).unwrap(); + + assert_user_item(user); + } +} diff --git a/src/tests/responses/provisioning/customer_ok.json b/src/tests/responses/provisioning/customer_ok.json new file mode 100644 index 0000000..4faf7be --- /dev/null +++ b/src/tests/responses/provisioning/customer_ok.json @@ -0,0 +1,27 @@ +{ + "id": 1, + "companyName": "string", + "customerContractType": "pay", + "quotaMax": 10000000, + "quotaUsed": 10, + "userMax": 100, + "userUsed": 100, + "cntInternalUser": 99, + "cntGuestUser": 1, + "createdAt": "2020-01-00T00:00:00.000Z", + "isLocked": false, + "trialDaysLeft": 0, + "updatedAt": "2020-01-00T00:00:00.000Z", + "lastLoginAt": "2020-01-00T00:00:00.000Z", + "customerAttributes": { + "items": [ + { + "key": "string", + "value": "string" + } + ] + }, + "providerCustomerId": "string", + "webhooksMax": 1, + "customerUuid": "string" + } \ No newline at end of file diff --git a/src/tests/responses/provisioning/customers_ok.json b/src/tests/responses/provisioning/customers_ok.json new file mode 100644 index 0000000..35e7939 --- /dev/null +++ b/src/tests/responses/provisioning/customers_ok.json @@ -0,0 +1,36 @@ +{ + "range": { + "offset": 0, + "limit": 0, + "total": 1 + }, + "items": [ + { + "id": 1, + "companyName": "string", + "customerContractType": "pay", + "quotaMax": 10000000, + "quotaUsed": 10, + "userMax": 100, + "userUsed": 100, + "cntInternalUser": 99, + "cntGuestUser": 1, + "createdAt": "2020-01-00T00:00:00.000Z", + "isLocked": false, + "trialDaysLeft": 0, + "updatedAt": "2020-01-00T00:00:00.000Z", + "lastLoginAt": "2020-01-00T00:00:00.000Z", + "customerAttributes": { + "items": [ + { + "key": "string", + "value": "string" + } + ] + }, + "providerCustomerId": "string", + "webhooksMax": 1, + "customerUuid": "string" + } + ] + } \ No newline at end of file diff --git a/src/tests/responses/provisioning/new_customer_ok.json b/src/tests/responses/provisioning/new_customer_ok.json new file mode 100644 index 0000000..0870ec7 --- /dev/null +++ b/src/tests/responses/provisioning/new_customer_ok.json @@ -0,0 +1,30 @@ +{ + "id": 1, + "companyName": "string", + "customerContractType": "pay", + "quotaMax": 10000000, + "userMax": 100, + "firstAdminUser": { + "firstName": "string", + "lastName": "string", + "userName": "string", + "authData": { + "method": "basic" + }, + "notifyUser": true, + "email": "string", + "phone": "string" + }, + "isLocked": false, + "trialDays": 0, + "customerAttributes": { + "items": [ + { + "key": "string", + "value": "string" + } + ] + }, + "providerCustomerId": "string", + "webhooksMax": 1 + } \ No newline at end of file diff --git a/src/tests/responses/provisioning/update_customer_ok.json b/src/tests/responses/provisioning/update_customer_ok.json new file mode 100644 index 0000000..589da99 --- /dev/null +++ b/src/tests/responses/provisioning/update_customer_ok.json @@ -0,0 +1,22 @@ +{ + "id": 1, + "companyName": "string", + "customerContractType": "pay", + "quotaMax": 10000000, + "userMax": 100, + "isLocked": false, + "trialDays": 0, + "customerAttributes": { + "items": [ + { + "key": "string", + "value": "string" + } + ] + }, + "providerCustomerId": "string", + "webhooksMax": 1, + "customerUuid": "string", + "createdAt": "2020-01-00T00:00:00.000Z", + "updatedAt": "2020-01-00T00:00:00.000Z" + } \ No newline at end of file diff --git a/src/tests/users.rs b/src/tests/users.rs index 312e163..be8524a 100644 --- a/src/tests/users.rs +++ b/src/tests/users.rs @@ -1,5 +1,5 @@ #[cfg(test)] -mod tests { +pub mod tests { use crate::{ tests::dracoon::get_connected_client, user::UserAuthData, @@ -7,7 +7,7 @@ mod tests { ListAllParams, SortOrder, Users, }; - fn assert_user_item(user: &UserItem) { + pub fn assert_user_item(user: &UserItem) { assert_eq!(user.id, 1); assert_eq!(user.user_name, "string"); assert_eq!(user.first_name, "string"); diff --git a/src/user/models.rs b/src/user/models.rs index 548f7e3..54b9c82 100644 --- a/src/user/models.rs +++ b/src/user/models.rs @@ -77,12 +77,19 @@ pub struct UserGroup { #[serde(rename_all = "camelCase")] #[allow(non_snake_case)] pub struct UpdateUserAccountRequest { + #[serde(skip_serializing_if = "Option::is_none")] user_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] accept_EULA: Option, + #[serde(skip_serializing_if = "Option::is_none")] first_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] last_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] email: Option, + #[serde(skip_serializing_if = "Option::is_none")] phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] language: Option, } diff --git a/src/users/models.rs b/src/users/models.rs index d013a35..37c4933 100644 --- a/src/users/models.rs +++ b/src/users/models.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::{ auth::DracoonErrorResponse, models::{ObjectExpiration, RangedItems}, - user::{RoleList}, + user::RoleList, utils::{parse_body, FromResponse}, DracoonClientError, FilterOperator, FilterQuery, SortOrder, SortQuery, }; @@ -100,14 +100,23 @@ impl FromResponse for LastAdminUserRoomList { pub struct CreateUserRequest { first_name: String, last_name: String, + #[serde(skip_serializing_if = "Option::is_none")] user_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] expiration: Option, + #[serde(skip_serializing_if = "Option::is_none")] receiver_language: Option, + #[serde(skip_serializing_if = "Option::is_none")] auth_data: Option, + #[serde(skip_serializing_if = "Option::is_none")] email: Option, + #[serde(skip_serializing_if = "Option::is_none")] notify_user: Option, + #[serde(skip_serializing_if = "Option::is_none")] is_nonmember_viewer: Option, + #[serde(skip_serializing_if = "Option::is_none")] mfa_config: Option, } @@ -225,15 +234,24 @@ impl CreateUserRequestBuilder { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct UpdateUserRequest { + #[serde(skip_serializing_if = "Option::is_none")] first_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] last_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] user_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] expiration: Option, + #[serde(skip_serializing_if = "Option::is_none")] receiver_language: Option, + #[serde(skip_serializing_if = "Option::is_none")] auth_data: Option, + #[serde(skip_serializing_if = "Option::is_none")] email: Option, - mf_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mfa_config: Option, } impl UpdateUserRequest { @@ -326,7 +344,7 @@ impl UpdateUserRequestBuilder { receiver_language: self.receiver_language, auth_data: self.auth_data, email: self.email, - mf_config: self.mf_config, + mfa_config: self.mf_config, } } } From ad43ddaece3f9d4eff9d01c5914b406838501b06 Mon Sep 17 00:00:00 2001 From: Octavio Simone Date: Thu, 24 Aug 2023 17:49:14 +0200 Subject: [PATCH 2/5] extend logging for up- & download --- src/auth/errors.rs | 42 ++++++-------- src/auth/models.rs | 63 ++++++++++++++------- src/nodes/download.rs | 126 ++++++++++++++++++++++++------------------ src/nodes/upload.rs | 109 ++++++++++++++++++++++++++++-------- 4 files changed, 218 insertions(+), 122 deletions(-) diff --git a/src/auth/errors.rs b/src/auth/errors.rs index 777426b..02f3189 100644 --- a/src/auth/errors.rs +++ b/src/auth/errors.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use dco3_crypto::DracoonCryptoError; -use reqwest_middleware::{Error as ReqError}; use reqwest::{Error as ClientError, Response}; +use reqwest_middleware::Error as ReqError; use thiserror::Error; use crate::{nodes::models::S3ErrorResponse, utils::FromResponse}; @@ -20,8 +20,8 @@ pub enum DracoonClientError { InvalidUrl(String), #[error("Invalid DRACOON path")] InvalidPath(String), - #[error("Connection to DRACOON failed")] - ConnectionFailed, + #[error("Connection to DRACOON failed: {0}")] + ConnectionFailed(String), #[error("Unknown error")] Unknown, #[error("Internal error")] @@ -44,47 +44,38 @@ pub enum DracoonClientError { impl From for DracoonClientError { fn from(value: ReqError) -> Self { - match value { ReqError::Middleware(error) => { - DracoonClientError::ConnectionFailed - + DracoonClientError::ConnectionFailed("Error in middleware".into()) }, ReqError::Reqwest(error) => { if error.is_timeout() { - return DracoonClientError::ConnectionFailed + return DracoonClientError::ConnectionFailed("Timeout".into()); } if error.is_connect() { - return DracoonClientError::ConnectionFailed + return DracoonClientError::ConnectionFailed("Connection failed".into()); } - - - DracoonClientError::Unknown - - }, + DracoonClientError::ConnectionFailed("Unknown".into()) + } } } } - impl From for DracoonClientError { - fn from(value: ClientError) -> Self { - - if value.is_timeout() { - return DracoonClientError::ConnectionFailed; + fn from(error: ClientError) -> Self { + if error.is_timeout() { + return DracoonClientError::ConnectionFailed("Timeout".into()); } - if value.is_connect() { - return DracoonClientError::ConnectionFailed; + if error.is_connect() { + return DracoonClientError::ConnectionFailed("Connection failed".into()); } - - DracoonClientError::Unknown + + DracoonClientError::ConnectionFailed("Unknown".into()) } } - - #[async_trait] impl FromResponse for DracoonClientError { async fn from_response(value: Response) -> Result { @@ -92,7 +83,6 @@ impl FromResponse for DracoonClientError { let error = value.json::().await?; return Ok(DracoonClientError::Http(error)); } - Err(DracoonClientError::Unknown) } } @@ -145,7 +135,7 @@ impl DracoonClientError { _ => false, } } - + /// Check if the error is an 409 Conflict error pub fn is_conflict(&self) -> bool { match self { diff --git a/src/auth/models.rs b/src/auth/models.rs index e6862af..0f62a80 100644 --- a/src/auth/models.rs +++ b/src/auth/models.rs @@ -1,12 +1,14 @@ -use std::fmt::{Formatter, Display}; - +use std::fmt::{Display, Formatter}; use url::ParseError; use chrono::Utc; use reqwest::{Response, StatusCode}; use serde::{Deserialize, Serialize}; -use crate::{constants::{GRANT_TYPE_REFRESH_TOKEN, GRANT_TYPE_AUTH_CODE, GRANT_TYPE_PASSWORD}, utils::parse_body}; +use crate::{ + constants::{GRANT_TYPE_AUTH_CODE, GRANT_TYPE_PASSWORD, GRANT_TYPE_REFRESH_TOKEN}, + utils::parse_body, +}; use super::{errors::DracoonClientError, Connection}; @@ -118,7 +120,12 @@ pub struct DracoonErrorResponse { impl Display for DracoonErrorResponse { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let dbg_info = self.debug_info.as_deref().unwrap_or("No details"); - write!(f, "{} - {dbg_info} ({})", self.message, self.code) + let error_code = self.error_code.unwrap_or(0); + write!( + f, + "{} {} - {dbg_info} ({})", + self.code, self.message, error_code + ) } } @@ -132,7 +139,7 @@ impl DracoonErrorResponse { error_code: None, } } - + /// Checks if error is 403 Forbidden pub fn is_forbidden(&self) -> bool { self.code == 403 @@ -142,12 +149,12 @@ impl DracoonErrorResponse { pub fn is_not_found(&self) -> bool { self.code == 404 } - + /// Checks if error is 409 Conflict pub fn is_conflict(&self) -> bool { self.code == 409 } - + /// Checks if error is 429 Too Many Requests pub fn is_too_many_requests(&self) -> bool { self.code == 429 @@ -157,12 +164,12 @@ impl DracoonErrorResponse { pub fn is_server_error(&self) -> bool { self.code >= 500 } - + /// Checks if error is a client error (4xx) pub fn is_client_error(&self) -> bool { self.code >= 400 && self.code < 500 } - + /// Checks if error is 401 Unauthorized pub fn is_unauthorized(&self) -> bool { self.code == 401 @@ -172,22 +179,33 @@ impl DracoonErrorResponse { pub fn is_bad_request(&self) -> bool { self.code == 400 } - + /// Checks if error is 402 Payment Required pub fn is_payment_required(&self) -> bool { self.code == 402 } - + /// Checks if error is 412 Precondition Failed pub fn is_precondition_failed(&self) -> bool { self.code == 412 } - + + // Returns DRACOON API error code if available + pub fn error_code(&self) -> Option { + self.error_code + } + + /// Returns the HTTP status code + pub fn code(&self) -> i32 { + self.code + } + /// Returns the error message pub fn error_message(&self) -> String { self.message.clone() } + /// Returns the debug info pub fn debug_info(&self) -> Option { self.debug_info.clone() } @@ -201,10 +219,16 @@ pub struct DracoonAuthErrorResponse { error_description: Option, } - impl Display for DracoonAuthErrorResponse { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "Error: {} ({})", self.error_description.clone().unwrap_or_else(|| "Unknown".to_string()), self.error) + write!( + f, + "Error: {} ({})", + self.error_description + .clone() + .unwrap_or_else(|| "Unknown".to_string()), + self.error + ) } } @@ -221,14 +245,17 @@ impl OAuth2TokenResponse { /// - Error: 4xx or 5xx pub enum StatusCodeState { Ok(StatusCode), - Error(StatusCode) + Error(StatusCode), } impl From for StatusCodeState { /// transforms a status code into a status code state fn from(value: StatusCode) -> Self { match value { - StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED | StatusCode::NO_CONTENT => StatusCodeState::Ok(value), + StatusCode::OK + | StatusCode::CREATED + | StatusCode::ACCEPTED + | StatusCode::NO_CONTENT => StatusCodeState::Ok(value), _ => StatusCodeState::Error(value), } } @@ -260,11 +287,9 @@ impl From for DracoonClientError { } } - impl From for DracoonClientError { /// transforms a URL parse error into a DRACOON client error fn from(_v: ParseError) -> Self { - Self::InvalidUrl("parsing url failed (invalid)".to_string()) } -} \ No newline at end of file +} diff --git a/src/nodes/download.rs b/src/nodes/download.rs index 5a24091..49dd9dc 100644 --- a/src/nodes/download.rs +++ b/src/nodes/download.rs @@ -1,5 +1,5 @@ use super::{ - models::{DownloadUrlResponse, Node, DownloadProgressCallback}, + models::{DownloadProgressCallback, DownloadUrlResponse, Node}, Download, }; use crate::{ @@ -11,11 +11,11 @@ use crate::{ Dracoon, }; use async_trait::async_trait; -use dco3_crypto::{FileKey, DracoonCrypto, DracoonRSACrypto, Decrypter, ChunkedEncryption}; +use dco3_crypto::{ChunkedEncryption, Decrypter, DracoonCrypto, DracoonRSACrypto, FileKey}; use futures_util::TryStreamExt; use reqwest::header::{self, CONTENT_LENGTH, RANGE}; use std::{cmp::min, io::Write}; -use tracing::debug; +use tracing::{debug, error}; #[async_trait] impl Download for Dracoon { @@ -98,7 +98,6 @@ impl DownloadInternal for Dracoon { "{DRACOON_API_PREFIX}/{NODES_BASE}/{FILES_BASE}/{node_id}/{NODES_DOWNLOAD_URL}" ); - let api_url = self.build_api_url(&url_part); let response = self @@ -126,7 +125,11 @@ impl DownloadInternal for Dracoon { .http .head(url) .send() - .await? + .await + .map_err(|err| { + debug!("Error while getting content length: {}", err); + err + })? .headers() .get(CONTENT_LENGTH) .and_then(|val| val.to_str().ok()) @@ -155,7 +158,11 @@ impl DownloadInternal for Dracoon { .get(url) .header(RANGE, range) .send() - .await?; + .await + .map_err(|err| { + error!("Error while downloading chunk: {}", err); + err + })?; // handle error if response.error_for_status_ref().is_err() { @@ -168,7 +175,9 @@ impl DownloadInternal for Dracoon { while let Some(chunk) = stream.try_next().await? { let len = chunk.len() as u64; - writer.write_all(&chunk).or(Err(DracoonClientError::IoError))?; + writer + .write_all(&chunk) + .or(Err(DracoonClientError::IoError))?; downloaded_bytes += len; // call progress callback if provided @@ -196,9 +205,8 @@ impl DownloadInternal for Dracoon { let file_key = self.get_file_key(node_id).await?; let keypair = self.get_keypair(None).await?; - - let plain_key = DracoonCrypto::decrypt_file_key(file_key, &keypair)?; + let plain_key = DracoonCrypto::decrypt_file_key(file_key, &keypair)?; // get content length from header let content_length = self @@ -206,7 +214,11 @@ impl DownloadInternal for Dracoon { .http .head(url) .send() - .await? + .await + .map_err(|err| { + debug!("Error while getting content length: {}", err); + err + })? .headers() .get(CONTENT_LENGTH) .and_then(|val| val.to_str().ok()) @@ -222,7 +234,6 @@ impl DownloadInternal for Dracoon { let mut crypter = DracoonCrypto::decrypter(plain_key, &mut buffer)?; - // offset (in bytes) let mut downloaded_bytes = 0u64; @@ -242,7 +253,11 @@ impl DownloadInternal for Dracoon { .get(url) .header(RANGE, range) .send() - .await?; + .await + .map_err(|err| { + error!("Error while downloading chunk: {}", err); + err + })?; // handle error if response.error_for_status_ref().is_err() { @@ -267,19 +282,19 @@ impl DownloadInternal for Dracoon { break; } } - } crypter.finalize()?; - writer.write_all(&buffer).or(Err(DracoonClientError::IoError))?; + writer + .write_all(&buffer) + .or(Err(DracoonClientError::IoError))?; Ok(()) } async fn get_file_key(&self, node_id: u64) -> Result { - let url_part = format!( - "{DRACOON_API_PREFIX}/{NODES_BASE}/{FILES_BASE}/{node_id}/{FILES_FILE_KEY}" - ); + let url_part = + format!("{DRACOON_API_PREFIX}/{NODES_BASE}/{FILES_BASE}/{node_id}/{FILES_FILE_KEY}"); let response = self .client @@ -305,7 +320,6 @@ mod tests { #[tokio::test] async fn test_get_download_url() { - let download_url_str = "https://test.dracoon.com/not/real/download_url"; let (dracoon, mut mock_server) = get_connected_client().await; @@ -324,12 +338,10 @@ mod tests { download_url_mock.assert(); assert_eq!(download_url.download_url, download_url_str); - } #[tokio::test] async fn test_get_file_key() { - let (dracoon, mut mock_server) = get_connected_client().await; let file_key_res = include_str!("../tests/responses/download/file_key_ok.json"); @@ -351,13 +363,10 @@ mod tests { assert_eq!(file_key.tag.unwrap(), "string"); // TODO: implement PartialEq for FileKeyVersion // assert_eq!(file_key.version, FileKeyVersion::RSA4096_AES256GCM); - - } - + #[tokio::test] async fn test_download_unencrypted() { - let (dracoon, mut mock_server) = get_connected_client().await; let content_length_mock = mock_server @@ -367,7 +376,9 @@ mod tests { .create(); // create bytes for mocking byte response - let mock_bytes: [u8; 16] = [0, 12, 33, 44, 55, 66, 77, 88, 99, 111, 222, 255, 0, 12, 33, 44]; + let mock_bytes: [u8; 16] = [ + 0, 12, 33, 44, 55, 66, 77, 88, 99, 111, 222, 255, 0, 12, 33, 44, + ]; let download_mock = mock_server .mock("GET", "/some/download/url") @@ -376,27 +387,27 @@ mod tests { .with_body(&mock_bytes) .create(); - let download_url = format!("{}some/download/url",dracoon.get_base_url().to_string()); + let download_url = format!("{}some/download/url", dracoon.get_base_url().to_string()); let buffer = Vec::with_capacity(16); - // create a writer + // create a writer let mut writer = std::io::BufWriter::new(buffer); - dracoon.download_unencrypted(&download_url, &mut writer, Some(16), None).await.unwrap(); + dracoon + .download_unencrypted(&download_url, &mut writer, Some(16), None) + .await + .unwrap(); content_length_mock.assert(); download_mock.assert(); assert_eq!(writer.into_inner().unwrap(), mock_bytes.to_vec()); - - } #[tokio::test] async fn test_download_encrypted() { - let (dracoon, mut mock_server) = get_connected_client().await; let content_length_mock = mock_server @@ -406,20 +417,24 @@ mod tests { .create(); // create bytes for mocking byte response - let mock_bytes: [u8; 16] = [0, 12, 33, 44, 55, 66, 77, 88, 99, 111, 222, 255, 0, 12, 33, 44]; + let mock_bytes: [u8; 16] = [ + 0, 12, 33, 44, 55, 66, 77, 88, 99, 111, 222, 255, 0, 12, 33, 44, + ]; let mock_bytes_compare = mock_bytes.clone(); let mock_bytes_encrypted = DracoonCrypto::encrypt(mock_bytes.to_vec()).unwrap(); let plain_key = mock_bytes_encrypted.1.clone(); - let keypair = DracoonCrypto::create_plain_user_keypair(dco3_crypto::UserKeyPairVersion::RSA4096).unwrap(); - let enc_keypair = DracoonCrypto::encrypt_private_key("TopSecret1234!", keypair.clone()).unwrap(); + let keypair = + DracoonCrypto::create_plain_user_keypair(dco3_crypto::UserKeyPairVersion::RSA4096) + .unwrap(); + let enc_keypair = + DracoonCrypto::encrypt_private_key("TopSecret1234!", keypair.clone()).unwrap(); let enc_keypair_json = serde_json::to_string(&enc_keypair).unwrap(); let file_key = DracoonCrypto::encrypt_file_key(plain_key, keypair).unwrap(); let file_key_json = serde_json::to_string(&file_key).unwrap(); - let download_mock = mock_server .mock("GET", "/some/download/url") .with_status(200) @@ -441,16 +456,22 @@ mod tests { .with_body(enc_keypair_json) .create(); - let download_url = format!("{}some/download/url",dracoon.get_base_url().to_string()); + let download_url = format!("{}some/download/url", dracoon.get_base_url().to_string()); - let _kp = dracoon.get_keypair(Some("TopSecret1234!".into())).await.unwrap(); + let _kp = dracoon + .get_keypair(Some("TopSecret1234!".into())) + .await + .unwrap(); let buffer = Vec::with_capacity(16); - // create a writer + // create a writer let mut writer = std::io::BufWriter::new(buffer); - dracoon.download_encrypted(&download_url, 1234, &mut writer, None, None).await.unwrap(); + dracoon + .download_encrypted(&download_url, 1234, &mut writer, None, None) + .await + .unwrap(); keypair_mock.assert(); @@ -461,22 +482,23 @@ mod tests { file_key_mock.assert(); assert_eq!(writer.into_inner().unwrap(), mock_bytes_compare.to_vec()); - } #[tokio::test] async fn test_download_encrypted_no_keypair() { - - let (dracoon, mut mock_server) = get_connected_client().await; // create bytes for mocking byte response - let mock_bytes: [u8; 16] = [0, 12, 33, 44, 55, 66, 77, 88, 99, 111, 222, 255, 0, 12, 33, 44]; + let mock_bytes: [u8; 16] = [ + 0, 12, 33, 44, 55, 66, 77, 88, 99, 111, 222, 255, 0, 12, 33, 44, + ]; let mock_bytes_encrypted = DracoonCrypto::encrypt(mock_bytes.to_vec()).unwrap(); let plain_key = mock_bytes_encrypted.1.clone(); - let keypair = DracoonCrypto::create_plain_user_keypair(dco3_crypto::UserKeyPairVersion::RSA4096).unwrap(); + let keypair = + DracoonCrypto::create_plain_user_keypair(dco3_crypto::UserKeyPairVersion::RSA4096) + .unwrap(); let file_key = DracoonCrypto::encrypt_file_key(plain_key, keypair).unwrap(); let file_key_json = serde_json::to_string(&file_key).unwrap(); @@ -488,31 +510,27 @@ mod tests { .with_body(file_key_json) .create(); - let download_url = format!("{}some/download/url",dracoon.get_base_url().to_string()); + let download_url = format!("{}some/download/url", dracoon.get_base_url().to_string()); let buffer = Vec::with_capacity(16); - // create a writer + // create a writer let mut writer = std::io::BufWriter::new(buffer); - let download_res = dracoon.download_encrypted(&download_url, 1234, &mut writer, None, None).await; + let download_res = dracoon + .download_encrypted(&download_url, 1234, &mut writer, None, None) + .await; assert!(download_res.is_err()); // TODO: implement PartialEq for DracoonClientError // assert_eq!(err, DracoonClientError::MissingEncryptionSecret); - } async fn test_download_unencrypted_node() { todo!() - } async fn test_download_full_encrypted_node() { todo!() } - - - - -} \ No newline at end of file +} diff --git a/src/nodes/upload.rs b/src/nodes/upload.rs index a2d7687..a3aabb6 100644 --- a/src/nodes/upload.rs +++ b/src/nodes/upload.rs @@ -26,6 +26,7 @@ use dco3_crypto::{ChunkedEncryption, DracoonCrypto, DracoonRSACrypto, Encrypter} use futures_util::{Stream, StreamExt}; use reqwest::{header, Body}; use tokio::io::{AsyncRead, AsyncReadExt, BufReader}; +use tracing::error; #[async_trait] impl Upload for Dracoon { @@ -273,7 +274,11 @@ impl UploadInternal for Dracoon '_, '_, >(self, file_upload_req) - .await?; + .await + .map_err(|err| { + error!("Error creating upload channel: {}", err); + err + })?; let fm = &file_meta.clone(); let mut s3_parts = Vec::new(); @@ -333,7 +338,10 @@ impl UploadInternal for Dracoon s3_parts.push(S3FileUploadPart::new(url_part, e_tag)); url_part += 1; } - Err(_) => return Err(DracoonClientError::IoError), + Err(err) => { + error!("Error reading file: {}", err); + return Err(DracoonClientError::IoError); + } } } } @@ -370,7 +378,11 @@ impl UploadInternal for Dracoon upload_channel.upload_id.clone(), url_req, ) - .await?; + .await + .map_err(|err| { + error!("Error creating S3 upload urls: {}", err); + err + })?; let url = url.urls.first().expect("Creating S3 url failed"); @@ -391,7 +403,10 @@ impl UploadInternal for Dracoon s3_parts.push(S3FileUploadPart::new(url_part, e_tag)); } - Err(_) => return Err(DracoonClientError::IoError), + Err(err) => { + error!("Error reading file: {}", err); + return Err(DracoonClientError::IoError); + } } // finalize upload @@ -405,7 +420,11 @@ impl UploadInternal for Dracoon upload_channel.upload_id.clone(), complete_upload_req, ) - .await?; + .await + .map_err(|err| { + error!("Error finalizing upload: {}", err); + err + })?; // get upload status // return node if upload is done @@ -417,7 +436,11 @@ impl UploadInternal for Dracoon self, upload_channel.upload_id.clone(), ) - .await?; + .await + .map_err(|err| { + error!("Error getting upload status: {}", err); + err + })?; match status_response.status { S3UploadStatus::Done => { @@ -426,11 +449,11 @@ impl UploadInternal for Dracoon .expect("Node must be set if status is done")); } S3UploadStatus::Error => { - return Err(DracoonClientError::Http( - status_response - .error_details - .expect("Error message must be set if status is error"), - )); + let response = status_response + .error_details + .expect("Error message must be set if status is error"); + error!("Error uploading file: {}", response); + return Err(DracoonClientError::Http(response)); } _ => { tokio::time::sleep(Duration::from_millis(sleep_duration)).await; @@ -504,7 +527,11 @@ impl UploadInternal for Dracoon '_, '_, >(self, file_upload_req) - .await?; + .await + .map_err(|err| { + error!("Error creating upload channel: {}", err); + err + })?; let fm = &file_meta.clone(); let mut s3_parts = Vec::new(); @@ -544,7 +571,11 @@ impl UploadInternal for Dracoon >( self, upload_channel.upload_id.clone(), url_req ) - .await?; + .await + .map_err(|err| { + error!("Error creating S3 upload urls: {}", err); + err + })?; let url = url.urls.first().expect("Creating S3 url failed"); // truncation is safe because chunk_size is 32 MB @@ -560,7 +591,11 @@ impl UploadInternal for Dracoon Some(curr_pos), cloneable_callback.clone(), ) - .await?; + .await + .map_err(|err| { + error!("Error uploading stream to S3: {}", err); + err + })?; s3_parts.push(S3FileUploadPart::new(url_part, e_tag)); url_part += 1; @@ -601,7 +636,11 @@ impl UploadInternal for Dracoon upload_channel.upload_id.clone(), url_req, ) - .await?; + .await + .map_err(|err| { + error!("Error creating S3 upload urls: {}", err); + err + })?; let url = url.urls.first().expect("Creating S3 url failed"); @@ -618,12 +657,19 @@ impl UploadInternal for Dracoon Some(curr_pos), cloneable_callback.clone(), ) - .await?; + .await + .map_err(|err| { + error!("Error uploading stream to S3: {}", err); + err + })?; s3_parts.push(S3FileUploadPart::new(url_part, e_tag)); } - Err(err) => return Err(DracoonClientError::IoError), + Err(err) => { + error!("Error reading file: {}", err); + return Err(DracoonClientError::IoError); + } } // finalize upload @@ -638,7 +684,10 @@ impl UploadInternal for Dracoon upload_channel.upload_id.clone(), complete_upload_req, ) - .await?; + .await.map_err(|err| { + error!("Error finalizing upload: {}", err); + err + })?; // get upload status // return node if upload is done @@ -650,7 +699,10 @@ impl UploadInternal for Dracoon self, upload_channel.upload_id.clone(), ) - .await?; + .await.map_err(|err| { + error!("Error getting upload status: {}", err); + err + })?; match status_response.status { S3UploadStatus::Done => { @@ -664,7 +716,10 @@ impl UploadInternal for Dracoon .expect("Node must be set if status is done") .id, ) - .await?; + .await.map_err(|err| { + error!("Error getting missing file keys: {}", err); + err + })?; // encrypt plain file key for each user let key_reqs = missing_keys @@ -695,7 +750,10 @@ impl UploadInternal for Dracoon self, key_reqs.into(), ) - .await?; + .await.map_err(|err| { + error!("Error setting file keys: {}", err); + err + })?; } return Ok(status_response @@ -758,10 +816,15 @@ impl UploadInternal for Dracoon .body(body) .header(header::CONTENT_LENGTH, chunk_size) .send() - .await?; + .await + .map_err(|e| { + error!("Connection error (S3 upload): {:?}", e); + e + })?; // handle error if res.error_for_status_ref().is_err() { + error!("Error uploading file to S3: {:?}", res.error_for_status_ref().unwrap_err()); let error = build_s3_error(res).await; return Err(error); } @@ -1214,7 +1277,7 @@ mod tests { let parent_node: Node = serde_json::from_str(include_str!("../tests/responses/nodes/node_ok.json")).unwrap(); - + // test 0KB file let mock_bytes: Vec = vec![]; From 5539ff01505a2b8a2b9b4a6d7413b6e297368645 Mon Sep 17 00:00:00 2001 From: Octavio Simone Date: Sun, 27 Aug 2023 18:29:51 +0200 Subject: [PATCH 3/5] remove duplicate import --- src/auth/errors.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/auth/errors.rs b/src/auth/errors.rs index d9eecd5..8b8671d 100644 --- a/src/auth/errors.rs +++ b/src/auth/errors.rs @@ -2,7 +2,6 @@ use async_trait::async_trait; use dco3_crypto::DracoonCryptoError; use reqwest::{Error as ClientError, Response}; use reqwest_middleware::Error as ReqError; -use reqwest_middleware::Error as ReqError; use thiserror::Error; use crate::{nodes::models::S3ErrorResponse, utils::FromResponse}; From 678284c829e661e513f18cf78da989c3853d4b96 Mon Sep 17 00:00:00 2001 From: Octavio Simone Date: Sun, 27 Aug 2023 21:09:14 +0200 Subject: [PATCH 4/5] add doc tests --- src/provisioning/mod.rs | 144 +++++++++++++++++++++++++++++++++++++ src/provisioning/models.rs | 4 +- 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/src/provisioning/mod.rs b/src/provisioning/mod.rs index b13dc81..51511fc 100644 --- a/src/provisioning/mod.rs +++ b/src/provisioning/mod.rs @@ -16,37 +16,181 @@ use crate::{ }; #[async_trait] +/// This trait contains all methods for customer provisioning. +/// To use this trait, you need to create a client in `Provisioning` state. +/// +/// ```no_run +/// +/// use dco3::{Dracoon, OAuth2Flow, CustomerProvisioning}; +/// +/// #[tokio::main] +/// async fn main() { +/// // the client only requires passing the base url and a provisioning token +/// // other API calls are *not* supported in this state. +/// let dracoon = Dracoon::builder() +/// .with_base_url("https://dracoon.team") +/// .with_provisioning_token("some_token") +/// .build_provisioning() +/// .unwrap(); +/// +/// // now you can use the client in provisioning state +/// let customers = dracoon.get_customers(None).await.unwrap(); +/// +/// } pub trait CustomerProvisioning { + /// Returns a list of customers + /// ```no_run + /// # use dco3::{Dracoon, OAuth2Flow, CustomerProvisioning}; + /// # #[tokio::main] + /// # async fn main() { + /// # let dracoon = Dracoon::builder() + /// # .with_base_url("https://dracoon.team") + /// # .with_provisioning_token("some_token") + /// # .build_provisioning() + /// # .unwrap(); + /// let customers = dracoon.get_customers(None).await.unwrap(); + /// # } async fn get_customers( &self, params: Option, ) -> Result; + /// Creates a new customer + /// ```no_run + /// # use dco3::{Dracoon, OAuth2Flow, CustomerProvisioning, provisioning::{FirstAdminUser, NewCustomerRequest}}; + /// # #[tokio::main] + /// # async fn main() { + /// # let dracoon = Dracoon::builder() + /// # .with_base_url("https://dracoon.team") + /// # .with_provisioning_token("some_token") + /// # .build_provisioning() + /// # .unwrap(); + /// let first_admin = FirstAdminUser::new_local("admin", "admin", None, "admin@localhost", None); + /// let customer = NewCustomerRequest::builder("pay", 100000, 100, first_admin).build(); + /// let customer = dracoon.create_customer(customer).await.unwrap(); + /// # } async fn create_customer( &self, req: NewCustomerRequest, ) -> Result; + /// Gets a customer by id + /// ```no_run + /// # use dco3::{Dracoon, OAuth2Flow, CustomerProvisioning, provisioning::{FirstAdminUser, NewCustomerRequest}}; + /// # #[tokio::main] + /// # async fn main() { + /// # let dracoon = Dracoon::builder() + /// # .with_base_url("https://dracoon.team") + /// # .with_provisioning_token("some_token") + /// # .build_provisioning() + /// # .unwrap(); + /// let customer = dracoon.get_customer(123, None).await.unwrap(); + /// + /// // include attributes + /// let customer = dracoon.get_customer(123, Some(true)).await.unwrap(); + /// # } async fn get_customer( &self, id: u64, include_attributes: Option, ) -> Result; + /// Updates a customer by id + /// ```no_run + /// # use dco3::{Dracoon, OAuth2Flow, CustomerProvisioning, provisioning::UpdateCustomerRequest}; + /// # #[tokio::main] + /// # async fn main() { + /// # let dracoon = Dracoon::builder() + /// # .with_base_url("https://dracoon.team") + /// # .with_provisioning_token("some_token") + /// # .build_provisioning() + /// # .unwrap(); + /// + /// let update = UpdateCustomerRequest::builder() + /// .with_company_name("Foo Inc.") + /// .build(); + + /// let customer = dracoon.update_customer(123, update).await.unwrap(); + /// + /// # } async fn update_customer( &self, id: u64, req: UpdateCustomerRequest, ) -> Result; + /// Deletes a customer by id + /// ```no_run + /// # use dco3::{Dracoon, OAuth2Flow, CustomerProvisioning}; + /// # #[tokio::main] + /// # async fn main() { + /// # let dracoon = Dracoon::builder() + /// # .with_base_url("https://dracoon.team") + /// # .with_provisioning_token("some_token") + /// # .build_provisioning() + /// # .unwrap(); + /// + /// dracoon.delete_customer(123).await.unwrap(); + /// + /// # } async fn delete_customer(&self, id: u64) -> Result<(), DracoonClientError>; + /// Returns a list of customer users + /// ```no_run + /// # use dco3::{Dracoon, OAuth2Flow, CustomerProvisioning}; + /// # #[tokio::main] + /// # async fn main() { + /// # let dracoon = Dracoon::builder() + /// # .with_base_url("https://dracoon.team") + /// # .with_provisioning_token("some_token") + /// # .build_provisioning() + /// # .unwrap(); + /// let users = dracoon.get_customer_users(123, None).await.unwrap(); + /// # } async fn get_customer_users(&self, id: u64, params: Option) -> Result; + /// Returns a list of customer attributes + /// ```no_run + /// # use dco3::{Dracoon, OAuth2Flow, CustomerProvisioning}; + /// # #[tokio::main] + /// # async fn main() { + /// # let dracoon = Dracoon::builder() + /// # .with_base_url("https://dracoon.team") + /// # .with_provisioning_token("some_token") + /// # .build_provisioning() + /// # .unwrap(); + /// let attributes = dracoon.get_customer_attributes(123, None).await.unwrap(); + /// # } async fn get_customer_attributes( &self, id: u64, params: Option, ) -> Result; + /// Updates / sets customer attributes + /// ```no_run + /// # use dco3::{Dracoon, OAuth2Flow, CustomerProvisioning, provisioning::CustomerAttributes}; + /// # #[tokio::main] + /// # async fn main() { + /// # let dracoon = Dracoon::builder() + /// # .with_base_url("https://dracoon.team") + /// # .with_provisioning_token("some_token") + /// # .build_provisioning() + /// # .unwrap(); + /// let mut attributes = CustomerAttributes::new(); + /// attributes.add_attribute("foo", "bar"); + /// let customer = dracoon.update_customer_attributes(123, attributes).await.unwrap(); + /// # } async fn update_customer_attributes( &self, id: u64, req: CustomerAttributes, ) -> Result; + /// Deletes customer attribute by key + /// ```no_run + /// # use dco3::{Dracoon, OAuth2Flow, CustomerProvisioning}; + /// # #[tokio::main] + /// # async fn main() { + /// # let dracoon = Dracoon::builder() + /// # .with_base_url("https://dracoon.team") + /// # .with_provisioning_token("some_token") + /// # .build_provisioning() + /// # .unwrap(); + /// dracoon.delete_customer_attribute(123, "foo".to_string()).await.unwrap(); + /// # } async fn delete_customer_attribute( &self, id: u64, diff --git a/src/provisioning/models.rs b/src/provisioning/models.rs index e3e2f1a..94fd65d 100644 --- a/src/provisioning/models.rs +++ b/src/provisioning/models.rs @@ -14,8 +14,8 @@ impl CustomerAttributes { CustomerAttributes::default() } - pub fn add_attribute(&mut self, key: String, value: String) { - let attrib = KeyValueEntry { key, value }; + pub fn add_attribute(&mut self, key: impl Into, value: impl Into) { + let attrib = KeyValueEntry { key: key.into(), value: value.into() }; self.items.push(attrib); } } From 20d72afc98c1716475650b6561debb775d7c549a Mon Sep 17 00:00:00 2001 From: Octavio Simone Date: Sun, 27 Aug 2023 21:09:33 +0200 Subject: [PATCH 5/5] version bump --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 1cf6dc4..d1ba9fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dco3" -version = "0.4.1" +version = "0.5.0" edition = "2021" authors = ["Octavio Simone"] repository = "https://github.com/unbekanntes-pferd/dco3"