diff --git a/Cargo.lock b/Cargo.lock index 311769e1..ff2049aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -227,6 +227,7 @@ dependencies = [ "backend_api", "candid", "candid_parser", + "chrono", "ic-cdk", "ic-cdk-macros", "ic-cdk-timers", @@ -235,7 +236,6 @@ dependencies = [ "rand", "rand_chacha", "rstest", - "rstest_reuse", "serde", "uuid", ] @@ -402,6 +402,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +dependencies = [ + "num-traits", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -1336,8 +1345,6 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "libc", - "rand_chacha", "rand_core", ] @@ -1356,9 +1363,6 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] [[package]] name = "redox_syscall" @@ -1456,18 +1460,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "rstest_reuse" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88530b681abe67924d42cca181d070e3ac20e0740569441a9e35a7cedd2b34a4" -dependencies = [ - "quote", - "rand", - "rustc_version", - "syn 2.0.48", -] - [[package]] name = "rustc_version" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 5ce1092d..1c652fad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,5 +21,4 @@ uuid = "1.6" mockall = "0.12" rstest = "0.18" -rstest_reuse = "0.6" async-std = { version = "1.5", features = ["attributes"] } diff --git a/src/backend/api/backend.did b/src/backend/api/backend.did index 148e2503..981fa04e 100644 --- a/src/backend/api/backend.did +++ b/src/backend/api/backend.did @@ -3,6 +3,13 @@ type Err = record { message : text; }; +type HistoryAction = variant { + create; + update; + delete; + restore; +}; + type UserConfig = variant { admin : record { bio : text; @@ -24,6 +31,23 @@ type GetMyUserProfileResponse = variant { err : Err; }; +type UserProfileHistoryEntry = record { + username : text; + config : UserConfig; +}; + +type GetMyUserProfileHistoryResponse = variant { + ok : record { + history : vec record { + action : HistoryAction; + date_time : text; + user : principal; + data : UserProfileHistoryEntry; + }; + }; + err : Err; +}; + type CreateMyUserProfileResponse = variant { ok : record { id : text; @@ -35,5 +59,6 @@ type CreateMyUserProfileResponse = variant { service : { get_my_user_profile : () -> (GetMyUserProfileResponse) query; + get_my_user_profile_history : () -> (GetMyUserProfileHistoryResponse) query; create_my_user_profile : () -> (CreateMyUserProfileResponse); }; diff --git a/src/backend/api/src/history.rs b/src/backend/api/src/history.rs new file mode 100644 index 00000000..f3673bba --- /dev/null +++ b/src/backend/api/src/history.rs @@ -0,0 +1,21 @@ +use candid::{CandidType, Deserialize, Principal}; + +#[derive(Debug, Clone, CandidType, Deserialize, PartialEq, Eq)] +pub enum HistoryAction { + #[serde(rename = "create")] + Create, + #[serde(rename = "update")] + Update, + #[serde(rename = "delete")] + Delete, + #[serde(rename = "restore")] + Restore, +} + +#[derive(Debug, Clone, CandidType, Deserialize, PartialEq, Eq)] +pub struct HistoryEntry { + pub action: HistoryAction, + pub date_time: String, + pub user: Principal, + pub data: T, +} diff --git a/src/backend/api/src/lib.rs b/src/backend/api/src/lib.rs index c513bcb3..d5963a9c 100644 --- a/src/backend/api/src/lib.rs +++ b/src/backend/api/src/lib.rs @@ -1,5 +1,7 @@ +mod history; mod result; mod user_profile; +pub use history::*; pub use result::*; pub use user_profile::*; diff --git a/src/backend/api/src/user_profile.rs b/src/backend/api/src/user_profile.rs index 1af2be97..9a47c03d 100644 --- a/src/backend/api/src/user_profile.rs +++ b/src/backend/api/src/user_profile.rs @@ -1,3 +1,4 @@ +use crate::HistoryEntry; use candid::{CandidType, Deserialize}; #[derive(Debug, Clone, CandidType, Deserialize, PartialEq, Eq)] @@ -29,3 +30,14 @@ pub struct CreateMyUserProfileResponse { pub username: String, pub config: UserConfig, } + +#[derive(Debug, Clone, CandidType, PartialEq, Eq)] +pub struct UserProfileHistoryEntry { + pub username: String, + pub config: UserConfig, +} + +#[derive(Debug, Clone, CandidType, PartialEq, Eq)] +pub struct GetMyUserProfileHistoryResponse { + pub history: Vec>, +} diff --git a/src/backend/impl/Cargo.toml b/src/backend/impl/Cargo.toml index 9233efe1..d7ad3fad 100644 --- a/src/backend/impl/Cargo.toml +++ b/src/backend/impl/Cargo.toml @@ -19,11 +19,12 @@ candid_parser.workspace = true serde.workspace = true uuid = { workspace = true, features = ["serde"] } +chrono = { version = "0.4", default-features = false, features = ["std"] } + rand = { version = "0.8", default-features = false } rand_chacha = { version = "0.3", default-features = false } [dev-dependencies] mockall.workspace = true rstest.workspace = true -rstest_reuse.workspace = true async-std.workspace = true diff --git a/src/backend/impl/src/controllers/user_profile_controller.rs b/src/backend/impl/src/controllers/user_profile_controller.rs index d1a14b92..c5822d74 100644 --- a/src/backend/impl/src/controllers/user_profile_controller.rs +++ b/src/backend/impl/src/controllers/user_profile_controller.rs @@ -1,19 +1,31 @@ use crate::{ repositories::UserProfileRepositoryImpl, - services::{UserProfileService, UserProfileServiceImpl}, - system_api::assert_principal_not_anonymous, + services::{ + AccessControlService, AccessControlServiceImpl, UserProfileService, UserProfileServiceImpl, + }, +}; +use backend_api::{ + ApiError, ApiResult, CreateMyUserProfileResponse, GetMyUserProfileHistoryResponse, + GetMyUserProfileResponse, }; -use backend_api::{ApiError, ApiResult, CreateMyUserProfileResponse, GetMyUserProfileResponse}; use candid::Principal; use ic_cdk::*; #[query] -async fn get_my_user_profile() -> ApiResult { +fn get_my_user_profile() -> ApiResult { let calling_principal = caller(); UserProfileController::default() .get_my_user_profile(calling_principal) - .await + .into() +} + +#[query] +fn get_my_user_profile_history() -> ApiResult { + let calling_principal = caller(); + + UserProfileController::default() + .get_my_user_profile_history(calling_principal) .into() } @@ -27,42 +39,67 @@ async fn create_my_user_profile() -> ApiResult { .into() } -struct UserProfileController { - user_profile_service: T, +struct UserProfileController { + access_control_service: A, + user_profile_service: U, } -impl Default for UserProfileController> { +impl Default + for UserProfileController< + AccessControlServiceImpl, + UserProfileServiceImpl, + > +{ fn default() -> Self { - Self::new(UserProfileServiceImpl::default()) + Self::new( + AccessControlServiceImpl::default(), + UserProfileServiceImpl::default(), + ) } } -impl UserProfileController { - fn new(user_profile_service: T) -> Self { +impl UserProfileController { + fn new(access_control_service: A, user_profile_service: U) -> Self { Self { + access_control_service, user_profile_service, } } - async fn get_my_user_profile( + fn get_my_user_profile( &self, calling_principal: Principal, ) -> Result { - assert_principal_not_anonymous(&calling_principal)?; + self.access_control_service + .assert_principal_not_anonymous(&calling_principal)?; let profile = self .user_profile_service - .get_my_user_profile(calling_principal) - .await?; + .get_my_user_profile(calling_principal)?; Ok(profile) } + fn get_my_user_profile_history( + &self, + calling_principal: Principal, + ) -> Result { + self.access_control_service + .assert_principal_not_anonymous(&calling_principal)?; + + let profile_history = self + .user_profile_service + .get_my_user_profile_history(calling_principal)?; + + Ok(profile_history) + } + async fn create_my_user_profile( &self, calling_principal: Principal, ) -> Result { - assert_principal_not_anonymous(&calling_principal)?; + self.access_control_service + .assert_principal_not_anonymous(&calling_principal)?; let profile = self .user_profile_service @@ -76,59 +113,79 @@ impl UserProfileController { #[cfg(test)] mod tests { use super::*; - use crate::{fixtures, services::MockUserProfileService}; + use crate::{ + fixtures, + mappings::{map_get_my_user_profile_history_response, map_get_my_user_profile_response}, + services::{MockAccessControlService, MockUserProfileService}, + }; use backend_api::UserConfig; use mockall::predicate::*; use rstest::*; #[rstest] - async fn get_my_user_profile() { + fn get_my_user_profile() { let calling_principal = fixtures::principal(); - let profile = GetMyUserProfileResponse { - id: "id".to_string(), - username: "username".to_string(), - config: UserConfig::Anonymous, - }; + let profile = + map_get_my_user_profile_response(fixtures::user_id(), fixtures::admin_user_profile()); - let mut service_mock = MockUserProfileService::new(); + let mut access_control_service_mock = MockAccessControlService::new(); + access_control_service_mock + .expect_assert_principal_not_anonymous() + .once() + .with(eq(calling_principal)) + .return_const(Ok(())); + + let mut user_profile_service_mock = MockUserProfileService::new(); let returned_profile = profile.clone(); - service_mock + user_profile_service_mock .expect_get_my_user_profile() .once() .with(eq(calling_principal)) - .return_once(move |_| Ok(returned_profile)); + .return_const(Ok(returned_profile)); - let controller = UserProfileController::new(service_mock); + let controller = + UserProfileController::new(access_control_service_mock, user_profile_service_mock); - let res = controller - .get_my_user_profile(calling_principal) - .await - .unwrap(); + let res = controller.get_my_user_profile(calling_principal).unwrap(); assert_eq!(res, profile); } #[rstest] - async fn get_my_user_profile_anonymous_principal() { + fn get_my_user_profile_anonymous_principal() { let calling_principal = Principal::anonymous(); + + let mut access_control_service_mock = MockAccessControlService::new(); + access_control_service_mock + .expect_assert_principal_not_anonymous() + .once() + .with(eq(calling_principal)) + .return_const(Err(ApiError::unauthenticated())); + let mut service_mock = MockUserProfileService::new(); service_mock.expect_get_my_user_profile().never(); - let controller = UserProfileController::new(service_mock); + let controller = UserProfileController::new(access_control_service_mock, service_mock); let res = controller .get_my_user_profile(calling_principal) - .await .unwrap_err(); assert_eq!(res, ApiError::unauthenticated()); } #[rstest] - async fn get_my_user_profile_no_profile() { + fn get_my_user_profile_no_profile() { let calling_principal = fixtures::principal(); let error = ApiError::not_found("User profile not found"); + let mut access_control_service_mock = MockAccessControlService::new(); + access_control_service_mock + .expect_assert_principal_not_anonymous() + .once() + .with(eq(calling_principal)) + .return_const(Ok(())); + let mut service_mock = MockUserProfileService::new(); let returned_error = error.clone(); service_mock @@ -137,16 +194,91 @@ mod tests { .with(eq(calling_principal)) .return_once(move |_| Err(returned_error)); - let controller = UserProfileController::new(service_mock); + let controller = UserProfileController::new(access_control_service_mock, service_mock); let res = controller .get_my_user_profile(calling_principal) - .await .unwrap_err(); assert_eq!(res, error); } + #[rstest] + fn get_my_user_profile_history() { + let calling_principal = fixtures::principal(); + let profile_history = + map_get_my_user_profile_history_response(fixtures::user_profile_history()); + + let mut access_control_service_mock = MockAccessControlService::new(); + access_control_service_mock + .expect_assert_principal_not_anonymous() + .once() + .with(eq(calling_principal)) + .return_const(Ok(())); + + let mut service_mock = MockUserProfileService::new(); + let returned_profile_history = profile_history.clone(); + service_mock + .expect_get_my_user_profile_history() + .once() + .with(eq(calling_principal)) + .return_const(Ok(returned_profile_history)); + + let controller = UserProfileController::new(access_control_service_mock, service_mock); + + let res = controller + .get_my_user_profile_history(calling_principal) + .unwrap(); + + assert_eq!(res, profile_history); + } + + #[rstest] + fn get_my_user_profile_history_anonymous_user() { + let calling_principal = Principal::anonymous(); + + let mut access_control_service_mock = MockAccessControlService::new(); + access_control_service_mock + .expect_assert_principal_not_anonymous() + .once() + .with(eq(calling_principal)) + .return_const(Err(ApiError::unauthenticated())); + + let mut service_mock = MockUserProfileService::new(); + service_mock.expect_get_my_user_profile_history().never(); + + let controller = UserProfileController::new(access_control_service_mock, service_mock); + + let res = controller + .get_my_user_profile_history(calling_principal) + .unwrap_err(); + + assert_eq!(res, ApiError::unauthenticated()); + } + + #[rstest] + fn get_my_user_profile_history_no_history() { + let calling_principal = Principal::anonymous(); + + let mut access_control_service_mock = MockAccessControlService::new(); + access_control_service_mock + .expect_assert_principal_not_anonymous() + .once() + .with(eq(calling_principal)) + .return_const(Err(ApiError::unauthenticated())); + + let mut service_mock = MockUserProfileService::new(); + service_mock.expect_get_my_user_profile_history().never(); + + let controller = UserProfileController::new(access_control_service_mock, service_mock); + + let res = controller + .get_my_user_profile_history(calling_principal) + .unwrap_err(); + + assert_eq!(res, ApiError::unauthenticated()); + } + #[rstest] async fn create_my_user_profile() { let calling_principal = fixtures::principal(); @@ -156,6 +288,13 @@ mod tests { config: UserConfig::Anonymous, }; + let mut access_control_service_mock = MockAccessControlService::new(); + access_control_service_mock + .expect_assert_principal_not_anonymous() + .once() + .with(eq(calling_principal)) + .return_const(Ok(())); + let mut service_mock = MockUserProfileService::new(); let returned_profile = profile.clone(); service_mock @@ -164,7 +303,7 @@ mod tests { .with(eq(calling_principal)) .return_once(move |_| Ok(returned_profile)); - let controller = UserProfileController::new(service_mock); + let controller = UserProfileController::new(access_control_service_mock, service_mock); let res = controller .create_my_user_profile(calling_principal) @@ -177,10 +316,18 @@ mod tests { #[rstest] async fn create_my_user_profile_anonymous_principal() { let calling_principal = Principal::anonymous(); + + let mut access_control_service_mock = MockAccessControlService::new(); + access_control_service_mock + .expect_assert_principal_not_anonymous() + .once() + .with(eq(calling_principal)) + .return_const(Err(ApiError::unauthenticated())); + let mut service_mock = MockUserProfileService::new(); service_mock.expect_create_my_user_profile().never(); - let controller = UserProfileController::new(service_mock); + let controller = UserProfileController::new(access_control_service_mock, service_mock); let res = controller .create_my_user_profile(calling_principal) diff --git a/src/backend/impl/src/fixtures/date_time.rs b/src/backend/impl/src/fixtures/date_time.rs new file mode 100644 index 00000000..46fd55c4 --- /dev/null +++ b/src/backend/impl/src/fixtures/date_time.rs @@ -0,0 +1,44 @@ +use crate::repositories::DateTime; +use chrono::{FixedOffset, NaiveDate, TimeZone, Utc}; +use rstest::*; + +const HOUR: i32 = 3600; + +#[fixture] +pub fn date_time_a() -> DateTime { + let tz = FixedOffset::east_opt(5 * HOUR).unwrap(); + let date_time = NaiveDate::from_ymd_opt(2021, 12, 4) + .unwrap() + .and_hms_opt(10, 20, 6) + .unwrap() + .and_local_timezone(tz) + .unwrap() + .naive_local(); + + DateTime::new(Utc.from_local_datetime(&date_time).unwrap()).unwrap() +} + +#[fixture] +pub fn date_time_b() -> DateTime { + let tz = FixedOffset::west_opt(8 * HOUR).unwrap(); + let date_time = NaiveDate::from_ymd_opt(2014, 4, 12) + .unwrap() + .and_hms_opt(18, 20, 53) + .unwrap() + .and_local_timezone(tz) + .unwrap() + .naive_local(); + + DateTime::new(Utc.from_local_datetime(&date_time).unwrap()).unwrap() +} + +#[fixture] +pub fn date_time_c() -> DateTime { + let date_time = NaiveDate::from_ymd_opt(1998, 8, 22) + .unwrap() + .and_hms_opt(21, 49, 36) + .unwrap() + .and_utc(); + + DateTime::new(date_time).unwrap() +} diff --git a/src/backend/impl/src/fixtures/mod.rs b/src/backend/impl/src/fixtures/mod.rs index 1061ca95..f45ceb5a 100644 --- a/src/backend/impl/src/fixtures/mod.rs +++ b/src/backend/impl/src/fixtures/mod.rs @@ -1,5 +1,7 @@ +mod date_time; mod id; mod user_profile; +pub use date_time::*; pub use id::*; pub use user_profile::*; diff --git a/src/backend/impl/src/fixtures/user_profile.rs b/src/backend/impl/src/fixtures/user_profile.rs index a1141c7d..97eb9a91 100644 --- a/src/backend/impl/src/fixtures/user_profile.rs +++ b/src/backend/impl/src/fixtures/user_profile.rs @@ -1,4 +1,7 @@ -use crate::repositories::{UserConfig, UserProfile}; +use crate::{ + fixtures::{date_time_a, principal}, + repositories::{HistoryAction, UserConfig, UserProfile, UserProfileHistoryEntry}, +}; use rstest::*; #[fixture] @@ -31,3 +34,15 @@ pub fn admin_user_profile() -> UserProfile { }, } } + +#[fixture] +pub fn user_profile_history() -> Vec { + vec![UserProfileHistoryEntry { + action: HistoryAction::Create, + principal: principal(), + date_time: date_time_a(), + data: UserProfile { + ..reviewer_user_profile() + }, + }] +} diff --git a/src/backend/impl/src/lib.rs b/src/backend/impl/src/lib.rs index 45e48c9d..c41a3cef 100644 --- a/src/backend/impl/src/lib.rs +++ b/src/backend/impl/src/lib.rs @@ -10,11 +10,6 @@ mod repositories; mod services; mod system_api; -// https://github.com/la10736/rstest/tree/master/rstest_reuse#cavelets -#[cfg(test)] -#[allow(clippy::single_component_path_imports)] -use rstest_reuse; - #[cfg(test)] mod fixtures; diff --git a/src/backend/impl/src/mappings/user_profile.rs b/src/backend/impl/src/mappings/user_profile.rs index 8dba12ff..065fb01f 100644 --- a/src/backend/impl/src/mappings/user_profile.rs +++ b/src/backend/impl/src/mappings/user_profile.rs @@ -1,5 +1,10 @@ -use crate::repositories::{UserConfig, UserId, UserProfile}; -use backend_api::{CreateMyUserProfileResponse, GetMyUserProfileResponse}; +use crate::repositories::{ + HistoryAction, UserConfig, UserId, UserProfile, UserProfileHistoryEntry, +}; +use backend_api::{ + CreateMyUserProfileResponse, GetMyUserProfileHistoryResponse, GetMyUserProfileResponse, + HistoryEntry, +}; impl From for backend_api::UserConfig { fn from(value: UserConfig) -> Self { @@ -19,6 +24,17 @@ impl From for backend_api::UserConfig { } } +impl From for backend_api::HistoryAction { + fn from(value: HistoryAction) -> Self { + match value { + HistoryAction::Create => backend_api::HistoryAction::Create, + HistoryAction::Update => backend_api::HistoryAction::Update, + HistoryAction::Delete => backend_api::HistoryAction::Delete, + HistoryAction::Restore => backend_api::HistoryAction::Restore, + } + } +} + pub fn map_get_my_user_profile_response( user_id: UserId, user_profile: UserProfile, @@ -40,3 +56,22 @@ pub fn map_create_my_user_profile_response( config: user_profile.config.into(), } } + +pub fn map_get_my_user_profile_history_response( + history: Vec, +) -> GetMyUserProfileHistoryResponse { + GetMyUserProfileHistoryResponse { + history: history + .into_iter() + .map(|entry| HistoryEntry { + action: entry.action.into(), + date_time: entry.date_time.to_string(), + user: entry.principal, + data: backend_api::UserProfileHistoryEntry { + username: entry.data.username, + config: entry.data.config.into(), + }, + }) + .collect(), + } +} diff --git a/src/backend/impl/src/repositories/memories/memory_manager.rs b/src/backend/impl/src/repositories/memories/memory_manager.rs index 2712d63d..92d94161 100644 --- a/src/backend/impl/src/repositories/memories/memory_manager.rs +++ b/src/backend/impl/src/repositories/memories/memory_manager.rs @@ -13,3 +13,4 @@ thread_local! { // everything else related to each memory region is kept in the appropriate file pub(super) const USER_PROFILES_MEMORY_ID: MemoryId = MemoryId::new(0); pub(super) const USER_PROFILE_PRINCIPAL_INDEX_MEMORY_ID: MemoryId = MemoryId::new(1); +pub(super) const USER_PROFILE_HISTORY_MEMORY_ID: MemoryId = MemoryId::new(2); diff --git a/src/backend/impl/src/repositories/memories/user_profile_memory.rs b/src/backend/impl/src/repositories/memories/user_profile_memory.rs index 0b21f127..12c42c28 100644 --- a/src/backend/impl/src/repositories/memories/user_profile_memory.rs +++ b/src/backend/impl/src/repositories/memories/user_profile_memory.rs @@ -1,12 +1,18 @@ use super::{ - Memory, MEMORY_MANAGER, USER_PROFILES_MEMORY_ID, USER_PROFILE_PRINCIPAL_INDEX_MEMORY_ID, + memory_manager::USER_PROFILE_HISTORY_MEMORY_ID, Memory, MEMORY_MANAGER, + USER_PROFILES_MEMORY_ID, USER_PROFILE_PRINCIPAL_INDEX_MEMORY_ID, +}; +use crate::repositories::{ + types::{UserId, UserProfile}, + UserProfileHistoryEntry, UserProfileHistoryKey, }; -use crate::repositories::types::{UserId, UserProfile}; use candid::Principal; use ic_stable_structures::BTreeMap; pub type UserProfileMemory = BTreeMap; pub type UserProfilePrincipalIndexMemory = BTreeMap; +pub type UserProfileHistoryMemory = + BTreeMap; pub fn init_user_profiles() -> UserProfileMemory { UserProfileMemory::init(get_user_profiles_memory()) @@ -16,6 +22,10 @@ pub fn init_user_profile_principal_index() -> UserProfilePrincipalIndexMemory { UserProfilePrincipalIndexMemory::init(get_user_profile_principal_index_memory()) } +pub fn init_user_profiles_history() -> UserProfileHistoryMemory { + UserProfileHistoryMemory::init(get_user_profiles_history_memory()) +} + fn get_user_profiles_memory() -> Memory { MEMORY_MANAGER.with(|m| m.borrow().get(USER_PROFILES_MEMORY_ID)) } @@ -23,3 +33,7 @@ fn get_user_profiles_memory() -> Memory { fn get_user_profile_principal_index_memory() -> Memory { MEMORY_MANAGER.with(|m| m.borrow().get(USER_PROFILE_PRINCIPAL_INDEX_MEMORY_ID)) } + +fn get_user_profiles_history_memory() -> Memory { + MEMORY_MANAGER.with(|m| m.borrow().get(USER_PROFILE_HISTORY_MEMORY_ID)) +} diff --git a/src/backend/impl/src/repositories/types/date_time.rs b/src/backend/impl/src/repositories/types/date_time.rs new file mode 100644 index 00000000..4185c725 --- /dev/null +++ b/src/backend/impl/src/repositories/types/date_time.rs @@ -0,0 +1,99 @@ +use backend_api::ApiError; +use candid::{ + types::{Type, TypeInner}, + CandidType, Deserialize, +}; +use chrono::{Datelike, Timelike}; +use ic_stable_structures::{storable::Bound, Storable}; +use std::{borrow::Cow, str::FromStr}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct DateTime(chrono::DateTime); + +const DATE_TIME_SIZE: u32 = 25; + +impl DateTime { + pub fn new(date_time: chrono::DateTime) -> Result { + Ok(Self(date_time.with_nanosecond(0).ok_or( + ApiError::internal(&format!("Failed to convert date time {:?}", date_time)), + )?)) + } + + pub fn min() -> Self { + Self(chrono::DateTime::::UNIX_EPOCH) + } + + pub fn max() -> Result { + Ok(Self( + chrono::DateTime::::MAX_UTC + .with_year(9999) + .ok_or_else(|| ApiError::internal("Failed to create max date time."))?, + )) + } +} + +impl ToString for DateTime { + fn to_string(&self) -> String { + self.0.to_rfc3339_opts(chrono::SecondsFormat::Secs, false) + } +} + +impl CandidType for DateTime { + fn _ty() -> Type { + TypeInner::Text.into() + } + + fn idl_serialize(&self, serializer: S) -> Result<(), S::Error> + where + S: candid::types::Serializer, + { + self.to_string().idl_serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for DateTime { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer) + .and_then(|date_time| { + chrono::DateTime::parse_from_rfc3339(&date_time) + .map_err(|_| serde::de::Error::custom("Invalid date time.")) + }) + .map(|date_time| Self(date_time.into())) + } +} + +impl Storable for DateTime { + fn to_bytes(&self) -> Cow<[u8]> { + Cow::Owned(self.to_string().as_bytes().to_vec()) + } + + fn from_bytes(bytes: Cow<[u8]>) -> Self { + Self(chrono::DateTime::from_str(&String::from_utf8(bytes.into_owned()).unwrap()).unwrap()) + } + + const BOUND: Bound = Bound::Bounded { + max_size: DATE_TIME_SIZE, + is_fixed_size: true, + }; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures; + use rstest::*; + + #[rstest] + #[case(fixtures::date_time_a())] + #[case(fixtures::date_time_b())] + #[case(fixtures::date_time_c())] + fn storable_impl_admin(#[case] date_time: DateTime) { + let serialized_date_time = date_time.to_bytes(); + let deserialized_date_time = DateTime::from_bytes(serialized_date_time); + + assert_eq!(date_time, deserialized_date_time); + } +} diff --git a/src/backend/impl/src/repositories/types/history.rs b/src/backend/impl/src/repositories/types/history.rs new file mode 100644 index 00000000..a24b48ab --- /dev/null +++ b/src/backend/impl/src/repositories/types/history.rs @@ -0,0 +1,73 @@ +use super::DateTime; +use crate::system_api::get_date_time; +use backend_api::ApiError; +use candid::{CandidType, Decode, Deserialize, Encode, Principal}; +use ic_stable_structures::{storable::Bound, Storable}; +use std::borrow::Cow; + +#[derive(Debug, CandidType, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum HistoryAction { + Create, + Update, + Delete, + Restore, +} + +#[derive(Debug, CandidType, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct HistoryEntry { + pub action: HistoryAction, + pub date_time: DateTime, + pub principal: Principal, + pub data: T, +} + +impl Storable for HistoryEntry +where + T: CandidType + for<'de> Deserialize<'de>, +{ + fn to_bytes(&self) -> Cow<[u8]> { + Cow::Owned(Encode!(self).unwrap()) + } + + fn from_bytes(bytes: Cow<[u8]>) -> Self { + Decode!(&bytes, Self).unwrap() + } + + const BOUND: Bound = Bound::Unbounded; +} + +impl HistoryEntry { + fn new(action: HistoryAction, principal: Principal, data: T) -> Result { + let date_time = get_date_time()?; + + Ok(Self { + action, + date_time: DateTime::new(date_time)?, + principal, + data, + }) + } + + pub fn create_action(calling_principal: Principal, data: T) -> Result { + Self::new(HistoryAction::Create, calling_principal, data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures; + use rstest::*; + + #[rstest] + fn storable_impl_user_profile() { + let user_profile = fixtures::reviewer_user_profile(); + let principal = fixtures::principal(); + let history_entry = HistoryEntry::create_action(principal, user_profile.clone()).unwrap(); + + let bytes = history_entry.to_bytes(); + let deserialized_history_entry = HistoryEntry::from_bytes(bytes); + + assert_eq!(history_entry, deserialized_history_entry); + } +} diff --git a/src/backend/impl/src/repositories/types/mod.rs b/src/backend/impl/src/repositories/types/mod.rs index b2049b31..965f1324 100644 --- a/src/backend/impl/src/repositories/types/mod.rs +++ b/src/backend/impl/src/repositories/types/mod.rs @@ -1,5 +1,11 @@ +mod date_time; +mod history; +mod user_profile_history; mod user_profile; mod uuid; +pub use date_time::*; +pub use history::*; +pub use user_profile_history::*; pub use user_profile::*; pub use uuid::*; diff --git a/src/backend/impl/src/repositories/types/user_profile.rs b/src/backend/impl/src/repositories/types/user_profile.rs index 606cd9ca..90478a73 100644 --- a/src/backend/impl/src/repositories/types/user_profile.rs +++ b/src/backend/impl/src/repositories/types/user_profile.rs @@ -43,11 +43,11 @@ impl UserProfile { } impl Storable for UserProfile { - fn to_bytes(&self) -> std::borrow::Cow<[u8]> { + fn to_bytes(&self) -> Cow<[u8]> { Cow::Owned(Encode!(self).unwrap()) } - fn from_bytes(bytes: std::borrow::Cow<[u8]>) -> Self { + fn from_bytes(bytes: Cow<[u8]>) -> Self { Decode!(bytes.as_ref(), Self).unwrap() } @@ -59,17 +59,12 @@ mod tests { use super::*; use crate::fixtures; use rstest::*; - use rstest_reuse::*; - #[template] #[rstest] #[case::anonymous_user(fixtures::anonymous_user_profile())] #[case::reviewer(fixtures::reviewer_user_profile())] #[case::admin(fixtures::admin_user_profile())] - fn user_profiles(#[case] profile: UserProfile) {} - - #[apply(user_profiles)] - fn storable_impl_admin(profile: UserProfile) { + fn storable_impl(#[case] profile: UserProfile) { let serialized_user_profile = profile.to_bytes(); let deserialized_user_profile = UserProfile::from_bytes(serialized_user_profile); diff --git a/src/backend/impl/src/repositories/types/user_profile_history.rs b/src/backend/impl/src/repositories/types/user_profile_history.rs new file mode 100644 index 00000000..d3df9b94 --- /dev/null +++ b/src/backend/impl/src/repositories/types/user_profile_history.rs @@ -0,0 +1,80 @@ +use super::{DateTime, HistoryEntry, UserId, UserProfile}; +use backend_api::ApiError; +use ic_stable_structures::{ + storable::{Blob, Bound}, + Storable, +}; +use std::ops::RangeBounds; + +pub type UserProfileHistoryEntry = HistoryEntry; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct UserProfileHistoryKey(Blob<{ <(UserId, DateTime)>::BOUND.max_size() as usize }>); + +impl UserProfileHistoryKey { + pub fn new(user_id: UserId, date_time: DateTime) -> Result { + Ok(Self( + Blob::try_from((user_id, date_time.clone()).to_bytes().as_ref()).map_err(|_| { + ApiError::internal(&format!( + "Failed to convert date time {:?} and user id {:?} to bytes.", + date_time, user_id + )) + })?, + )) + } +} + +impl Storable for UserProfileHistoryKey { + fn to_bytes(&self) -> std::borrow::Cow<[u8]> { + self.0.to_bytes() + } + + fn from_bytes(bytes: std::borrow::Cow<[u8]>) -> Self { + Self(Blob::from_bytes(bytes)) + } + + const BOUND: Bound = Bound::Bounded { + max_size: <(UserId, DateTime)>::BOUND.max_size(), + is_fixed_size: true, + }; +} + +pub struct UserProfileHistoryRange { + start_bound: UserProfileHistoryKey, + end_bound: UserProfileHistoryKey, +} + +impl UserProfileHistoryRange { + pub fn new(user_id: UserId) -> Result { + Ok(Self { + start_bound: UserProfileHistoryKey::new(user_id, DateTime::min())?, + end_bound: UserProfileHistoryKey::new(user_id, DateTime::max()?)?, + }) + } +} + +impl RangeBounds for UserProfileHistoryRange { + fn start_bound(&self) -> std::ops::Bound<&UserProfileHistoryKey> { + std::ops::Bound::Included(&self.start_bound) + } + + fn end_bound(&self) -> std::ops::Bound<&UserProfileHistoryKey> { + std::ops::Bound::Included(&self.end_bound) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures; + use rstest::*; + + #[rstest] + fn storable_impl() { + let key = UserProfileHistoryKey::new(fixtures::user_id(), fixtures::date_time_a()).unwrap(); + let serialized_key = key.to_bytes(); + let deserialized_key = UserProfileHistoryKey::from_bytes(serialized_key); + + assert_eq!(key, deserialized_key); + } +} diff --git a/src/backend/impl/src/repositories/types/uuid.rs b/src/backend/impl/src/repositories/types/uuid.rs index f3303184..f6a4ec93 100644 --- a/src/backend/impl/src/repositories/types/uuid.rs +++ b/src/backend/impl/src/repositories/types/uuid.rs @@ -1,8 +1,5 @@ use backend_api::ApiError; -use candid::{ - types::{Serializer, Type, TypeInner}, - CandidType, Deserialize, -}; +use candid::Deserialize; use ic_stable_structures::{storable::Bound, Storable}; use std::borrow::Cow; use uuid::{Builder, Uuid as UuidImpl}; @@ -30,19 +27,6 @@ impl ToString for Uuid { } } -impl CandidType for Uuid { - fn _ty() -> Type { - TypeInner::Vec(TypeInner::Nat8.into()).into() - } - - fn idl_serialize(&self, serializer: S) -> Result<(), S::Error> - where - S: Serializer, - { - serializer.serialize_blob(self.0.as_bytes()) - } -} - impl Storable for Uuid { fn to_bytes(&self) -> std::borrow::Cow<[u8]> { Cow::Borrowed(self.0.as_bytes()) diff --git a/src/backend/impl/src/repositories/user_profile_repository.rs b/src/backend/impl/src/repositories/user_profile_repository.rs index fa97218a..72865486 100644 --- a/src/backend/impl/src/repositories/user_profile_repository.rs +++ b/src/backend/impl/src/repositories/user_profile_repository.rs @@ -1,6 +1,8 @@ use super::{ - init_user_profile_principal_index, init_user_profiles, UserId, UserProfile, UserProfileMemory, - UserProfilePrincipalIndexMemory, + init_user_profile_principal_index, init_user_profiles, + memories::{init_user_profiles_history, UserProfileHistoryMemory}, + UserId, UserProfile, UserProfileHistoryEntry, UserProfileHistoryKey, UserProfileHistoryRange, + UserProfileMemory, UserProfilePrincipalIndexMemory, }; use backend_api::ApiError; use candid::Principal; @@ -11,9 +13,14 @@ pub trait UserProfileRepository { fn get_user_profile_by_principal(&self, principal: &Principal) -> Option<(UserId, UserProfile)>; + fn get_user_profile_history_by_principal( + &self, + principal: &Principal, + ) -> Result>, ApiError>; + async fn create_user_profile( &self, - principal: Principal, + calling_principal: Principal, user_profile: UserProfile, ) -> Result; } @@ -38,17 +45,35 @@ impl UserProfileRepository for UserProfileRepositoryImpl { }) } + fn get_user_profile_history_by_principal( + &self, + principal: &Principal, + ) -> Result>, ApiError> { + self.get_user_id_by_principal(principal) + .map(|user_id| self.get_user_profile_history_by_user_id(user_id)) + .transpose() + } + async fn create_user_profile( &self, - principal: Principal, + calling_principal: Principal, user_profile: UserProfile, ) -> Result { let user_id = UserId::new().await?; STATE.with_borrow_mut(|s| { - s.profiles.insert(user_id, user_profile); - s.principal_index.insert(principal, user_id); - }); + s.profiles.insert(user_id, user_profile.clone()); + s.principal_index.insert(calling_principal, user_id); + + let history_entry = + UserProfileHistoryEntry::create_action(calling_principal, user_profile)?; + s.profiles_history.insert( + UserProfileHistoryKey::new(user_id, history_entry.date_time.clone())?, + history_entry, + ); + + Ok(()) + })?; Ok(user_id) } @@ -66,11 +91,24 @@ impl UserProfileRepositoryImpl { fn get_user_profile_by_user_id(&self, user_id: &UserId) -> Option { STATE.with_borrow(|s| s.profiles.get(user_id)) } + + fn get_user_profile_history_by_user_id( + &self, + user_id: UserId, + ) -> Result, ApiError> { + STATE.with_borrow(|s| { + Ok(s.profiles_history + .range(UserProfileHistoryRange::new(user_id)?) + .map(|(_, entry)| entry) + .collect()) + }) + } } struct UserProfileState { profiles: UserProfileMemory, principal_index: UserProfilePrincipalIndexMemory, + profiles_history: UserProfileHistoryMemory, } impl Default for UserProfileState { @@ -78,6 +116,7 @@ impl Default for UserProfileState { Self { profiles: init_user_profiles(), principal_index: init_user_profile_principal_index(), + profiles_history: init_user_profiles_history(), } } } @@ -89,7 +128,11 @@ thread_local! { #[cfg(test)] mod tests { use super::*; - use crate::fixtures; + use crate::{ + fixtures, + repositories::{DateTime, HistoryAction}, + system_api::get_date_time, + }; use rstest::*; #[rstest] @@ -99,6 +142,7 @@ mod tests { async fn create_and_get_user_profile_by_principal(#[case] profile: UserProfile) { STATE.set(UserProfileState::default()); let principal = fixtures::principal(); + let date_time = get_date_time().unwrap(); let repository = UserProfileRepositoryImpl::default(); let user_id = repository @@ -107,7 +151,21 @@ mod tests { .unwrap(); let result = repository.get_user_profile_by_principal(&principal); + let history_result = repository + .get_user_profile_history_by_principal(&principal) + .unwrap() + .unwrap(); - assert_eq!(result, Some((user_id, profile))); + assert_eq!(result, Some((user_id, profile.clone()))); + assert_eq!(history_result.len(), 1); + assert_eq!( + history_result[0], + UserProfileHistoryEntry { + action: HistoryAction::Create, + principal, + date_time: DateTime::new(date_time).unwrap(), + data: profile, + } + ); } } diff --git a/src/backend/impl/src/services/access_control_service.rs b/src/backend/impl/src/services/access_control_service.rs new file mode 100644 index 00000000..19bd766e --- /dev/null +++ b/src/backend/impl/src/services/access_control_service.rs @@ -0,0 +1,207 @@ +use crate::repositories::{UserConfig, UserProfileRepository, UserProfileRepositoryImpl}; +use backend_api::ApiError; +use candid::Principal; + +#[cfg_attr(test, mockall::automock)] +pub trait AccessControlService { + fn assert_principal_not_anonymous(&self, calling_principal: &Principal) + -> Result<(), ApiError>; + + async fn assert_principal_is_admin(&self, calling_principal: Principal) + -> Result<(), ApiError>; +} + +pub struct AccessControlServiceImpl { + user_profile_repository: T, +} + +impl Default for AccessControlServiceImpl { + fn default() -> Self { + Self::new(UserProfileRepositoryImpl::default()) + } +} + +impl AccessControlService for AccessControlServiceImpl { + fn assert_principal_not_anonymous( + &self, + calling_principal: &Principal, + ) -> Result<(), ApiError> { + if calling_principal == &Principal::anonymous() { + return Err(ApiError::unauthenticated()); + } + + Ok(()) + } + + async fn assert_principal_is_admin( + &self, + calling_principal: Principal, + ) -> Result<(), ApiError> { + let (_id, profile) = self + .user_profile_repository + .get_user_profile_by_principal(&calling_principal) + .ok_or_else(|| { + ApiError::not_found(&format!( + "Principal {} must have a profile to call this endpoint", + &calling_principal.to_text() + )) + })?; + + if !matches!(profile.config, UserConfig::Admin { .. }) { + return Err(ApiError::permission_denied(&format!( + "Principal {} must be an admin to call this endpoint", + &calling_principal.to_text() + ))); + } + + Ok(()) + } +} + +impl AccessControlServiceImpl { + fn new(user_profile_repository: T) -> Self { + Self { + user_profile_repository, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{fixtures, repositories::MockUserProfileRepository}; + use mockall::predicate::*; + use rstest::*; + + #[rstest] + fn assert_principal_not_anonymous() { + let calling_principal = fixtures::principal(); + + let repository_mock = MockUserProfileRepository::new(); + let service = AccessControlServiceImpl::new(repository_mock); + + service + .assert_principal_not_anonymous(&calling_principal) + .unwrap(); + } + + #[rstest] + fn assert_principal_not_anonymous_anonymous_principal() { + let calling_principal = Principal::anonymous(); + + let repository_mock = MockUserProfileRepository::new(); + let service = AccessControlServiceImpl::new(repository_mock); + + let result = service + .assert_principal_not_anonymous(&calling_principal) + .unwrap_err(); + + assert_eq!(result, ApiError::unauthenticated()); + } + + #[rstest] + async fn assert_principal_is_admin() { + let calling_principal = fixtures::principal(); + let id = fixtures::user_id(); + let profile = fixtures::admin_user_profile(); + + let mut repository_mock = MockUserProfileRepository::new(); + repository_mock + .expect_get_user_profile_by_principal() + .once() + .with(eq(calling_principal)) + .return_const(Some((id, profile))); + + let service = AccessControlServiceImpl::new(repository_mock); + + service + .assert_principal_is_admin(calling_principal) + .await + .unwrap(); + } + + #[rstest] + async fn assert_principal_is_admin_no_profile() { + let calling_principal = fixtures::principal(); + + let mut repository_mock = MockUserProfileRepository::new(); + repository_mock + .expect_get_user_profile_by_principal() + .once() + .with(eq(calling_principal)) + .return_const(None); + + let service = AccessControlServiceImpl::new(repository_mock); + + let result = service + .assert_principal_is_admin(calling_principal) + .await + .unwrap_err(); + + assert_eq!( + result, + ApiError::not_found(&format!( + "Principal {} must have a profile to call this endpoint", + &calling_principal.to_text() + )) + ); + } + + #[rstest] + async fn assert_principal_anonymous_user() { + let calling_principal = fixtures::principal(); + let id = fixtures::user_id(); + let profile = fixtures::anonymous_user_profile(); + + let mut repository_mock = MockUserProfileRepository::new(); + repository_mock + .expect_get_user_profile_by_principal() + .once() + .with(eq(calling_principal)) + .return_const(Some((id, profile))); + + let service = AccessControlServiceImpl::new(repository_mock); + + let result = service + .assert_principal_is_admin(calling_principal) + .await + .unwrap_err(); + + assert_eq!( + result, + ApiError::permission_denied(&format!( + "Principal {} must be an admin to call this endpoint", + &calling_principal.to_text() + )) + ); + } + + #[rstest] + async fn assert_principal_reviewer() { + let calling_principal = fixtures::principal(); + let id = fixtures::user_id(); + let profile = fixtures::reviewer_user_profile(); + + let mut repository_mock = MockUserProfileRepository::new(); + repository_mock + .expect_get_user_profile_by_principal() + .once() + .with(eq(calling_principal)) + .return_const(Some((id, profile))); + + let service = AccessControlServiceImpl::new(repository_mock); + + let result = service + .assert_principal_is_admin(calling_principal) + .await + .unwrap_err(); + + assert_eq!( + result, + ApiError::permission_denied(&format!( + "Principal {} must be an admin to call this endpoint", + &calling_principal.to_text() + )) + ); + } +} diff --git a/src/backend/impl/src/services/init_service.rs b/src/backend/impl/src/services/init_service.rs index 9a375932..3c547e08 100644 --- a/src/backend/impl/src/services/init_service.rs +++ b/src/backend/impl/src/services/init_service.rs @@ -54,6 +54,7 @@ mod tests { let mut repository_mock = MockUserProfileRepository::new(); repository_mock .expect_create_user_profile() + .once() .with(eq(calling_principal), eq(profile.clone())) .return_const(Ok(id)); diff --git a/src/backend/impl/src/services/mod.rs b/src/backend/impl/src/services/mod.rs index 8765a094..ee88e733 100644 --- a/src/backend/impl/src/services/mod.rs +++ b/src/backend/impl/src/services/mod.rs @@ -1,5 +1,7 @@ +mod access_control_service; mod init_service; mod user_profile_service; +pub use access_control_service::*; pub use init_service::*; pub use user_profile_service::*; diff --git a/src/backend/impl/src/services/user_profile_service.rs b/src/backend/impl/src/services/user_profile_service.rs index 86d9799f..e0ec229c 100644 --- a/src/backend/impl/src/services/user_profile_service.rs +++ b/src/backend/impl/src/services/user_profile_service.rs @@ -1,17 +1,28 @@ use crate::{ - mappings::{map_create_my_user_profile_response, map_get_my_user_profile_response}, + mappings::{ + map_create_my_user_profile_response, map_get_my_user_profile_history_response, + map_get_my_user_profile_response, + }, repositories::{UserProfile, UserProfileRepository, UserProfileRepositoryImpl}, }; -use backend_api::{ApiError, CreateMyUserProfileResponse, GetMyUserProfileResponse}; +use backend_api::{ + ApiError, CreateMyUserProfileResponse, GetMyUserProfileHistoryResponse, + GetMyUserProfileResponse, +}; use candid::Principal; #[cfg_attr(test, mockall::automock)] pub trait UserProfileService { - async fn get_my_user_profile( + fn get_my_user_profile( &self, calling_principal: Principal, ) -> Result; + fn get_my_user_profile_history( + &self, + calling_principal: Principal, + ) -> Result; + async fn create_my_user_profile( &self, calling_principal: Principal, @@ -29,7 +40,7 @@ impl Default for UserProfileServiceImpl { } impl UserProfileService for UserProfileServiceImpl { - async fn get_my_user_profile( + fn get_my_user_profile( &self, calling_principal: Principal, ) -> Result { @@ -46,6 +57,23 @@ impl UserProfileService for UserProfileServiceImpl Ok(map_get_my_user_profile_response(id, profile)) } + fn get_my_user_profile_history( + &self, + calling_principal: Principal, + ) -> Result { + let history = self + .user_profile_repository + .get_user_profile_history_by_principal(&calling_principal)? + .ok_or_else(|| { + ApiError::not_found(&format!( + "User profile history for principal {} not found", + &calling_principal.to_text() + )) + })?; + + Ok(map_get_my_user_profile_history_response(history)) + } + async fn create_my_user_profile( &self, calling_principal: Principal, @@ -72,6 +100,7 @@ impl UserProfileServiceImpl { mod tests { use super::*; use crate::{fixtures, repositories::MockUserProfileRepository}; + use backend_api::{HistoryAction, HistoryEntry, UserProfileHistoryEntry}; use mockall::predicate::*; use rstest::*; @@ -84,15 +113,13 @@ mod tests { let mut repository_mock = MockUserProfileRepository::new(); repository_mock .expect_get_user_profile_by_principal() + .once() .with(eq(calling_principal)) .return_const(Some((fixtures::user_id(), profile.clone()))); let service = UserProfileServiceImpl::new(repository_mock); - let res = service - .get_my_user_profile(calling_principal) - .await - .unwrap(); + let res = service.get_my_user_profile(calling_principal).unwrap(); assert_eq!( res, @@ -111,15 +138,13 @@ mod tests { let mut repository_mock = MockUserProfileRepository::new(); repository_mock .expect_get_user_profile_by_principal() + .once() .with(eq(calling_principal)) .return_const(None); let service = UserProfileServiceImpl::new(repository_mock); - let res = service - .get_my_user_profile(calling_principal) - .await - .unwrap_err(); + let res = service.get_my_user_profile(calling_principal).unwrap_err(); assert_eq!( res, @@ -130,6 +155,66 @@ mod tests { ); } + #[rstest] + fn get_my_user_profile_history() { + let calling_principal = fixtures::principal(); + let history = fixtures::user_profile_history(); + + let mut repository_mock = MockUserProfileRepository::new(); + repository_mock + .expect_get_user_profile_history_by_principal() + .once() + .with(eq(calling_principal)) + .return_const(Ok(Some(history.clone()))); + + let service = UserProfileServiceImpl::new(repository_mock); + + let res = service + .get_my_user_profile_history(calling_principal) + .unwrap(); + + assert_eq!( + res, + GetMyUserProfileHistoryResponse { + history: vec![HistoryEntry { + action: HistoryAction::Create, + date_time: history[0].clone().date_time.to_string(), + user: history[0].clone().principal, + data: UserProfileHistoryEntry { + username: history[0].clone().data.username, + config: history[0].clone().data.config.into(), + } + }] + } + ) + } + + #[rstest] + fn get_my_user_profile_history_no_history() { + let calling_principal = fixtures::principal(); + + let mut repository_mock = MockUserProfileRepository::new(); + repository_mock + .expect_get_user_profile_history_by_principal() + .once() + .with(eq(calling_principal)) + .return_const(Ok(None)); + + let service = UserProfileServiceImpl::new(repository_mock); + + let res = service + .get_my_user_profile_history(calling_principal) + .unwrap_err(); + + assert_eq!( + res, + ApiError::not_found(&format!( + "User profile history for principal {} not found", + &calling_principal.to_text() + )) + ) + } + #[rstest] async fn create_my_user_profile() { let calling_principal = fixtures::principal(); @@ -139,6 +224,7 @@ mod tests { let mut repository_mock = MockUserProfileRepository::new(); repository_mock .expect_create_user_profile() + .once() .with(eq(calling_principal), eq(profile.clone())) .return_const(Ok(id)); diff --git a/src/backend/impl/src/system_api/mod.rs b/src/backend/impl/src/system_api/mod.rs index 6b5c53ae..81c55857 100644 --- a/src/backend/impl/src/system_api/mod.rs +++ b/src/backend/impl/src/system_api/mod.rs @@ -1,5 +1,5 @@ -mod principal; mod rand; +mod time; -pub use principal::*; pub use rand::*; +pub use time::*; diff --git a/src/backend/impl/src/system_api/principal.rs b/src/backend/impl/src/system_api/principal.rs deleted file mode 100644 index 5262b65d..00000000 --- a/src/backend/impl/src/system_api/principal.rs +++ /dev/null @@ -1,10 +0,0 @@ -use backend_api::ApiError; -use candid::Principal; - -pub fn assert_principal_not_anonymous(principal: &Principal) -> Result<(), ApiError> { - if principal == &Principal::anonymous() { - return Err(ApiError::unauthenticated()); - } - - Ok(()) -} diff --git a/src/backend/impl/src/system_api/time.rs b/src/backend/impl/src/system_api/time.rs new file mode 100644 index 00000000..1b414938 --- /dev/null +++ b/src/backend/impl/src/system_api/time.rs @@ -0,0 +1,28 @@ +use backend_api::ApiError; + +pub fn get_date_time() -> Result, ApiError> { + #[cfg(target_family = "wasm")] + let date_time = { + static NS_PER_S: u64 = 1_000_000_000; + + let timestamp_ns = ic_cdk::api::time(); + let timestamp_s: i64 = (timestamp_ns / NS_PER_S).try_into().map_err(|_| { + ApiError::internal(&format!( + "Failed to convert timestamp {} from nanoseconds to seconds", + timestamp_ns + )) + })?; + + chrono::DateTime::from_timestamp(timestamp_s, 0).ok_or_else(|| { + ApiError::internal(&format!( + "Failed to convert timestamp {} to DateTime", + timestamp_s + )) + })? + }; + + #[cfg(not(target_family = "wasm"))] + let date_time = chrono::DateTime::::UNIX_EPOCH; + + Ok(date_time) +} diff --git a/src/backend/integration/src/support/date.ts b/src/backend/integration/src/support/date.ts new file mode 100644 index 00000000..ea8017d5 --- /dev/null +++ b/src/backend/integration/src/support/date.ts @@ -0,0 +1,30 @@ +function pad(num: number): string { + return num < 10 ? '0' + String(num) : String(num); +} + +export function dateToRfc3339(date: Date): string { + const offset = -date.getTimezoneOffset(); + const sign = offset >= 0 ? '+' : '-'; + + const absOffset = Math.abs(offset); + const padOffset = absOffset < 600 ? '0' : ''; + const offsetHours = Math.floor(absOffset / 60); + + const offsetMinutes = pad(absOffset % 60); + const offsetString = `${sign}${padOffset}${offsetHours}:${offsetMinutes}`; + + return ( + date.getFullYear() + + '-' + + pad(date.getMonth() + 1) + + '-' + + pad(date.getDate()) + + 'T' + + pad(date.getHours()) + + ':' + + pad(date.getMinutes()) + + ':' + + pad(date.getSeconds()) + + offsetString + ); +} diff --git a/src/backend/integration/src/support/index.ts b/src/backend/integration/src/support/index.ts index 8418a5c6..e83dd9f3 100644 --- a/src/backend/integration/src/support/index.ts +++ b/src/backend/integration/src/support/index.ts @@ -1,3 +1,4 @@ +export * from './date'; export * from './identity'; export * from './response'; export * from './wasm'; diff --git a/src/backend/integration/src/support/wasm.ts b/src/backend/integration/src/support/wasm.ts index e9227804..6c10e602 100644 --- a/src/backend/integration/src/support/wasm.ts +++ b/src/backend/integration/src/support/wasm.ts @@ -18,7 +18,10 @@ export const BACKEND_WASM_PATH = resolve( export async function setupBackendCanister( pic: PocketIc, + initialDate: Date = new Date(), ): Promise> { + await pic.setTime(initialDate.getTime()); + const fixture = await pic.setupCanister<_SERVICE>( idlFactory, BACKEND_WASM_PATH, @@ -28,8 +31,6 @@ export async function setupBackendCanister( ); // make sure init timers run - await pic.resetTime(); - await pic.tick(); await pic.tick(); await pic.tick(); diff --git a/src/backend/integration/src/tests/user_profile.spec.ts b/src/backend/integration/src/tests/user_profile.spec.ts index 1d9d7f98..ea0156e9 100644 --- a/src/backend/integration/src/tests/user_profile.spec.ts +++ b/src/backend/integration/src/tests/user_profile.spec.ts @@ -4,6 +4,7 @@ import { PocketIc, type Actor, generateRandomIdentity } from '@hadronous/pic'; import { anonymousIdentity, controllerIdentity, + dateToRfc3339, extractErrResponse, extractOkResponse, setupBackendCanister, @@ -12,10 +13,11 @@ import { describe('User Profile', () => { let actor: Actor<_SERVICE>; let pic: PocketIc; + const currentDate = new Date(1988, 1, 14, 0, 0, 0, 0); beforeEach(async () => { pic = await PocketIc.create(); - const fixture = await setupBackendCanister(pic); + const fixture = await setupBackendCanister(pic, currentDate); actor = fixture.actor; }); @@ -37,6 +39,9 @@ describe('User Profile', () => { const res = await actor.get_my_user_profile(); const resOk = extractOkResponse(res); + const historyRes = await actor.get_my_user_profile_history(); + const historyOk = extractOkResponse(historyRes); + expect(resOk).toEqual({ id: expect.any(String), username: 'Admin', @@ -46,6 +51,16 @@ describe('User Profile', () => { }, }, }); + expect(historyOk.history).toHaveLength(1); + expect(historyOk.history[0]).toEqual({ + action: { create: null }, + user: controllerIdentity.getPrincipal(), + date_time: dateToRfc3339(currentDate), + data: { + username: resOk.username, + config: resOk.config, + }, + }); }); it('should not return a profile that does not exist', async () => { @@ -77,18 +92,42 @@ describe('User Profile', () => { actor.setIdentity(alice); const aliceGetRes = await actor.get_my_user_profile(); const aliceGet = extractOkResponse(aliceGetRes); + const aliceGetHistoryRes = await actor.get_my_user_profile_history(); + const aliceGetHistory = extractOkResponse(aliceGetHistoryRes); actor.setIdentity(bob); const bobGetRes = await actor.get_my_user_profile(); const bobGet = extractOkResponse(bobGetRes); + const bobGetHistoryRes = await actor.get_my_user_profile_history(); + const bobGetHistory = extractOkResponse(bobGetHistoryRes); expect(aliceCreate.id).toBeString(); expect(aliceCreate.username).toBe('Anonymous'); expect(aliceCreate.config).toEqual({ anonymous: null }); + expect(aliceGetHistory.history).toHaveLength(1); + expect(aliceGetHistory.history[0]).toEqual({ + action: { create: null }, + user: alice.getPrincipal(), + date_time: dateToRfc3339(currentDate), + data: { + username: aliceCreate.username, + config: aliceCreate.config, + }, + }); expect(bobCreate.id).toBeString(); expect(bobCreate.username).toBe('Anonymous'); expect(bobCreate.config).toEqual({ anonymous: null }); + expect(bobGetHistory.history).toHaveLength(1); + expect(bobGetHistory.history[0]).toEqual({ + action: { create: null }, + user: bob.getPrincipal(), + date_time: dateToRfc3339(currentDate), + data: { + username: bobCreate.username, + config: bobCreate.config, + }, + }); expect(aliceCreate.id).not.toEqual(bobCreate.id);