diff --git a/Cargo.lock b/Cargo.lock index 6ae13ce..baa4654 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1981,9 +1981,11 @@ version = "0.1.0" dependencies = [ "async-stream", "async-trait", + "chrono", "dotenv", "futures", "futures-util", + "once_cell", "portfolio-core", "portfolio-entity", "portfolio-migration", diff --git a/api/Cargo.toml b/api/Cargo.toml index ac54819..794181b 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -20,9 +20,15 @@ dotenv = "^0.15" serde_json = { version = "^1.0" } +chrono = "^0.4" + portfolio-entity = { path = "../entity" } portfolio-migration = { path = "../migration" } portfolio-core = { path = "../core" } [dependencies.sea-orm-rocket] version = "^0.5" + + +[dev-dependencies] +once_cell = "1.9.0" \ No newline at end of file diff --git a/api/src/lib.rs b/api/src/lib.rs index 81ee0d1..d5752f4 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -12,6 +12,7 @@ mod guards; mod pool; mod requests; mod routes; +pub mod test; use pool::Db; @@ -29,8 +30,7 @@ async fn run_migrations(rocket: Rocket) -> fairing::Result { Ok(rocket) } -#[tokio::main] -async fn start() -> Result<(), rocket::Error> { +pub fn rocket() -> Rocket{ rocket::build() .attach(Db::init()) .attach(AdHoc::try_on_ignite("Migrations", run_migrations)) @@ -81,6 +81,11 @@ async fn start() -> Result<(), rocket::Error> { routes::admin::list_candidates, ]) .register("/", catchers![]) +} + +#[tokio::main] +async fn start() -> Result<(), rocket::Error> { + rocket() .launch() .await .map(|_| ()) diff --git a/api/src/pool.rs b/api/src/pool.rs index b031c6c..d3a45a6 100644 --- a/api/src/pool.rs +++ b/api/src/pool.rs @@ -1,8 +1,9 @@ -use portfolio_core::sea_orm; - +use portfolio_core::{sea_orm::{self}}; use async_trait::async_trait; +#[cfg(not(test))] use sea_orm::ConnectOptions; use sea_orm_rocket::{rocket::figment::Figment, Database}; +#[cfg(not(test))] use std::time::Duration; #[derive(Database, Debug)] @@ -20,22 +21,32 @@ impl sea_orm_rocket::Pool for SeaOrmPool { type Connection = sea_orm::DatabaseConnection; + #[cfg(test)] + async fn init(_figment: &Figment) -> Result { + let conn = portfolio_core::util::get_memory_sqlite_connection().await; + crate::test::tests::run_test_migrations(&conn).await; + return Ok(Self { conn }); + } + + #[cfg(not(test))] async fn init(_figment: &Figment) -> Result { dotenv::dotenv().ok(); + let database_url = std::env::var("DATABASE_URL").unwrap(); let mut options: ConnectOptions = database_url.into(); options .max_connections(1024) .min_connections(0) .connect_timeout(Duration::from_secs(3)); - - /* options + + /* options .max_connections(config.max_connections as u32) .min_connections(config.min_connections.unwrap_or_default()) .connect_timeout(Duration::from_secs(config.connect_timeout)); - if let Some(idle_timeout) = config.idle_timeout { - options.idle_timeout(Duration::from_secs(idle_timeout)); - } */ + if let Some(idle_timeout) = config.idle_timeout { + options.idle_timeout(Duration::from_secs(idle_timeout)); + } */ + let conn = sea_orm::Database::connect(options).await?; Ok(SeaOrmPool { conn }) @@ -44,4 +55,4 @@ impl sea_orm_rocket::Pool for SeaOrmPool { fn borrow(&self) -> &Self::Connection { &self.conn } -} +} \ No newline at end of file diff --git a/api/src/routes/admin.rs b/api/src/routes/admin.rs index e4eb796..1e9daaa 100644 --- a/api/src/routes/admin.rs +++ b/api/src/routes/admin.rs @@ -1,4 +1,4 @@ -use std::net::SocketAddr; +use std::net::{SocketAddr, IpAddr, Ipv4Addr}; use portfolio_core::{ crypto::random_8_char_string, @@ -17,9 +17,10 @@ use crate::{guards::request::auth::AdminAuth, pool::Db, requests}; pub async fn login( conn: Connection<'_, Db>, login_form: Json, - ip_addr: SocketAddr, + // ip_addr: SocketAddr, // TODO uncomment in production cookies: &CookieJar<'_>, ) -> Result> { + let ip_addr: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 0); let db = conn.into_inner(); let session_token_key = AdminService::login( db, @@ -155,4 +156,63 @@ pub async fn get_candidate_portfolio( .map_err(|e| Custom(Status::from_code(e.code()).unwrap(), e.to_string()))?; Ok(portfolio) +} + +#[cfg(test)] +pub mod tests { + use rocket::{local::blocking::Client, http::{Cookie, Status}}; + + use crate::test::tests::{test_client, ADMIN_PASSWORD, ADMIN_ID}; + + pub fn admin_login(client: &Client) -> (Cookie, Cookie) { + let response = client + .post("/admin/login") + .body(format!( + "{{ + \"admin_id\": {}, + \"password\": \"{}\" + }}", + ADMIN_ID, ADMIN_PASSWORD + )) + .dispatch(); + + println!("{:?}", response); + ( + response.cookies().get("id").unwrap().to_owned(), + response.cookies().get("key").unwrap().to_owned(), + ) + } + + fn create_candidate( + client: &Client, + cookies: (Cookie, Cookie), + id: i32, + pid: String, + ) -> String { + let response = client + .post("/admin/create") + .body(format!( + "{{ + \"application_id\": {}, + \"personal_id_number\": \"{}\" + }}", + id, pid + )) + .cookie(cookies.0) + .cookie(cookies.1) + .dispatch(); + + assert_eq!(response.status(), Status::Ok); + + response.into_string().unwrap() + } + + #[test] + fn test_create_candidate() { + let client = test_client().lock().unwrap(); + let cookies = admin_login(&client); + let password = create_candidate(&client, cookies, 1031511, "0".to_string()); + + assert_eq!(password.len(), 8); + } } \ No newline at end of file diff --git a/api/src/routes/candidate.rs b/api/src/routes/candidate.rs index d73ddee..7068bb6 100644 --- a/api/src/routes/candidate.rs +++ b/api/src/routes/candidate.rs @@ -1,4 +1,4 @@ -use std::net::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use portfolio_core::candidate_details::ApplicationDetails; use portfolio_core::services::application_service::ApplicationService; @@ -19,9 +19,10 @@ use crate::{guards::request::auth::CandidateAuth, pool::Db, requests}; pub async fn login( conn: Connection<'_, Db>, login_form: Json, - ip_addr: SocketAddr, + // ip_addr: SocketAddr, // TODO uncomment in production cookies: &CookieJar<'_>, ) -> Result> { + let ip_addr: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 0); let db = conn.into_inner(); let session_token_key = CandidateService::login( db, @@ -84,18 +85,14 @@ pub async fn add_details( #[post("/get_details")] pub async fn get_details( conn: Connection<'_, Db>, - session: CandidateAuth + session: CandidateAuth, ) -> Result, Custom> { let db = conn.into_inner(); let private_key = session.get_private_key(); let candidate: entity::candidate::Model = session.into(); - // let handle = tokio::spawn(async move { - let details = ApplicationService::decrypt_all_details(private_key, - db, - candidate.application - ) + let details = ApplicationService::decrypt_all_details(private_key, db, candidate.application) .await .map_err(|e| { Custom( @@ -302,4 +299,158 @@ pub async fn download_portfolio(session: CandidateAuth) -> Result, Custo } Ok(file.unwrap()) -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use portfolio_core::{candidate_details::ApplicationDetails, crypto, sea_orm::prelude::Uuid}; + use rocket::{ + http::{Cookie, Status}, + local::blocking::Client, + }; + + use crate::{test::tests::{test_client, APPLICATION_ID, CANDIDATE_PASSWORD}, routes::admin::tests::admin_login}; + + fn candidate_login(client: &Client) -> (Cookie, Cookie) { + let response = client + .post("/candidate/login") + .body(format!( + "{{ + \"application_id\": {}, + \"password\": \"{}\" + }}", + APPLICATION_ID, CANDIDATE_PASSWORD + )) + .dispatch(); + + ( + response.cookies().get("id").unwrap().to_owned(), + response.cookies().get("key").unwrap().to_owned(), + ) + } + + const CANDIDATE_DETAILS: &'static str = "{ + \"name\": \"idk\", + \"surname\": \"idk\", + \"birthplace\": \"Praha 1\", + \"birthdate\": \"2015-09-18\", + \"address\": \"Stefanikova jidelna\", + \"telephone\": \"000111222333\", + \"citizenship\": \"Czech Republic\", + \"email\": \"magor@magor.cz\", + \"sex\": \"MALE\", + \"study\": \"KB\", + \"parent_name\": \"maminka\", + \"parent_surname\": \"chad\", + \"parent_telephone\": \"420111222333\", + \"parent_email\": \"maminka@centrum.cz\" + }"; + + #[test] + fn test_login_valid_credentials() { + let client = test_client().lock().unwrap(); + let _response = candidate_login(&client); + } + + #[test] + fn test_auth_candidate() { + let client = test_client().lock().unwrap(); + let cookies = candidate_login(&client); + let response = client + .get("/candidate/whoami") + .cookie(cookies.0) + .cookie(cookies.1) + .dispatch(); + + assert_eq!(response.status(), Status::Ok); + assert_eq!(response.into_string().unwrap(), APPLICATION_ID.to_string()); + } + + #[test] + fn test_add_get_candidate_details() { + let client = test_client().lock().unwrap(); + let cookies = candidate_login(&client); + + let details_orig: ApplicationDetails = serde_json::from_str(CANDIDATE_DETAILS).unwrap(); + + let response = client + .post("/candidate/add/details") + .cookie(cookies.0.clone()) + .cookie(cookies.1.clone()) + .body(CANDIDATE_DETAILS.to_string()) + .dispatch(); + + assert_eq!(response.status(), Status::Ok); + + let response = client + .post("/candidate/get_details") + .cookie(cookies.0) + .cookie(cookies.1) + .dispatch(); + + assert_eq!(response.status(), Status::Ok); + + let details_resp: ApplicationDetails = + serde_json::from_str(&response.into_string().unwrap()).unwrap(); + assert_eq!(details_orig, details_resp); + } + + #[test] + fn test_invalid_token_every_secured_endpoint() { + let client = test_client().lock().unwrap(); + + let id = Cookie::new("id", Uuid::new_v4().to_string()); + let (private_key, _) = crypto::create_identity(); + let key = Cookie::new("key", private_key); + + let response = client + .post("/candidate/add/details") + .cookie(id.clone()) + .cookie(key.clone()) + .body(CANDIDATE_DETAILS.to_string()) + .dispatch(); + assert_eq!(response.status(), Status::Unauthorized); + + let response = client + .post("/candidate/get_details") + .cookie(id.clone()) + .cookie(key.clone()) + .dispatch(); + assert_eq!(response.status(), Status::Unauthorized); + + let response = client + .get("/candidate/whoami") + .cookie(id.clone()) + .cookie(key.clone()) + .dispatch(); + assert_eq!(response.status(), Status::Unauthorized); + } + + #[test] + fn test_admin_token_on_secured_candidate_endpoints() { + let client = test_client().lock().unwrap(); + let cookies = admin_login(&client); + + let response = client + .post("/candidate/add/details") + .cookie(cookies.0.clone()) + .cookie(cookies.1.clone()) + .body(CANDIDATE_DETAILS.to_string()) + .dispatch(); + assert_eq!(response.status(), Status::Unauthorized); + + let response = client + .post("/candidate/get_details") + .cookie(cookies.0.clone()) + .cookie(cookies.1.clone()) + .dispatch(); + assert_eq!(response.status(), Status::Unauthorized); + + let response = client + .get("/candidate/whoami") + .cookie(cookies.0.clone()) + .cookie(cookies.1.clone()) + .dispatch(); + assert_eq!(response.status(), Status::Unauthorized); + } +} diff --git a/api/src/test.rs b/api/src/test.rs new file mode 100644 index 0000000..919c206 --- /dev/null +++ b/api/src/test.rs @@ -0,0 +1,62 @@ +#[cfg(test)] +pub mod tests { + use crate::rocket; + use entity::admin; + use once_cell::sync::OnceCell; + use portfolio_core::{ + crypto, + sea_orm::{ActiveModelTrait, DbConn, Set}, + services::application_service::ApplicationService, + }; + use rocket::{ + local::blocking::Client, + }; + use std::sync::Mutex; + + pub const ADMIN_ID: i32 = 1; + pub const ADMIN_PASSWORD: &'static str = "test"; + + pub const APPLICATION_ID: i32 = 103151; + pub const CANDIDATE_PASSWORD: &'static str = "test"; + pub const PERSONAL_ID_NUMBER: &'static str = "0101010000"; + + pub async fn run_test_migrations(db: &DbConn) { + let (pubkey, priv_key) = crypto::create_identity(); + let priv_key = crypto::encrypt_password(priv_key, ADMIN_PASSWORD.to_string()) + .await + .unwrap(); + let password_hash = crypto::hash_password(ADMIN_PASSWORD.to_string()) + .await + .unwrap(); + + admin::ActiveModel { + id: Set(ADMIN_ID), + name: Set("admin pepa".to_string()), + public_key: Set(pubkey), + private_key: Set(priv_key), + password: Set(password_hash), + created_at: Set(chrono::Utc::now().naive_utc()), + updated_at: Set(chrono::Utc::now().naive_utc()), + } + .insert(db) + .await + .unwrap(); + + ApplicationService::create_candidate_with_parent( + db, + APPLICATION_ID, + &CANDIDATE_PASSWORD.to_string(), + PERSONAL_ID_NUMBER.to_string(), + ) + .await + .unwrap(); + } + + pub fn test_client() -> &'static Mutex { + static INSTANCE: OnceCell> = OnceCell::new(); + INSTANCE.get_or_init(|| { + let rocket = rocket(); + Mutex::from(Client::tracked(rocket).expect("valid rocket instance")) + }) + } +} diff --git a/core/src/candidate_details.rs b/core/src/candidate_details.rs index 47c483e..ea4616a 100644 --- a/core/src/candidate_details.rs +++ b/core/src/candidate_details.rs @@ -185,7 +185,7 @@ impl TryFrom<(candidate::Model, parent::Model)> for EncryptedApplicationDetails } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct ApplicationDetails { // Candidate pub name: String, diff --git a/core/src/error.rs b/core/src/error.rs index 206d579..8c34271 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -8,6 +8,8 @@ pub enum ServiceError { InvalidApplicationId, #[error("Invalid credentials")] InvalidCredentials, + #[error("Unauthorized")] + Unauthorized, #[error("Forbidden")] Forbidden, #[error("Session expired, please login agai")] @@ -65,6 +67,7 @@ impl ServiceError { match self { ServiceError::InvalidApplicationId => 400, ServiceError::InvalidCredentials => 401, + ServiceError::Unauthorized => 401, ServiceError::Forbidden => 403, ServiceError::ExpiredSession => 401, ServiceError::JwtError => 500, diff --git a/core/src/services/admin_service.rs b/core/src/services/admin_service.rs index 76c719a..9e23b65 100644 --- a/core/src/services/admin_service.rs +++ b/core/src/services/admin_service.rs @@ -41,7 +41,7 @@ impl AdminService { pub async fn auth(db: &DbConn, session_uuid: Uuid) -> Result { match SessionService::auth_user_session(db, session_uuid).await? { AdminUser::Admin(admin) => Ok(admin), - AdminUser::Candidate(_) => unreachable!(), + AdminUser::Candidate(_) => Err(ServiceError::Unauthorized), } } } diff --git a/core/src/services/candidate_service.rs b/core/src/services/candidate_service.rs index 9c12b72..b5ca5e0 100644 --- a/core/src/services/candidate_service.rs +++ b/core/src/services/candidate_service.rs @@ -215,7 +215,7 @@ impl CandidateService { match SessionService::auth_user_session(db, session_uuid).await { Ok(user) => match user { AdminUser::Candidate(candidate) => Ok(candidate), - AdminUser::Admin(_) => unreachable!(), + AdminUser::Admin(_) => Err(ServiceError::Unauthorized), }, Err(e) => Err(e), } diff --git a/core/src/util.rs b/core/src/util.rs index ab47922..a988644 100644 --- a/core/src/util.rs +++ b/core/src/util.rs @@ -1,30 +1,22 @@ -#[cfg(test)] +use entity::{admin, candidate, parent, session}; +use sea_orm::{Schema, Database, DbConn}; +use sea_orm::{sea_query::TableCreateStatement, ConnectionTrait, DbBackend}; + + pub async fn get_memory_sqlite_connection() -> sea_orm::DbConn { - use entity::{admin, candidate, parent, session}; - use sea_orm::{Schema, Database, DbConn}; - use sea_orm::{sea_query::TableCreateStatement, ConnectionTrait, DbBackend}; - let base_url = "sqlite::memory:"; - let db: DbConn = Database::connect(base_url).await.unwrap(); + let db: DbConn = Database::connect(base_url).await.unwrap(); - let schema = Schema::new(DbBackend::Sqlite); - let stmt: TableCreateStatement = schema.create_table_from_entity(candidate::Entity); - let stmt2: TableCreateStatement = schema.create_table_from_entity(admin::Entity); - let stmt3: TableCreateStatement = schema.create_table_from_entity(session::Entity); - let stmt4: TableCreateStatement = schema.create_table_from_entity(parent::Entity); - db.execute(db.get_database_backend().build(&stmt)) - .await - .unwrap(); - db.execute(db.get_database_backend().build(&stmt2)) - .await - .unwrap(); - db.execute(db.get_database_backend().build(&stmt3)) - .await - .unwrap(); - db.execute(db.get_database_backend().build(&stmt4)) - .await - .unwrap(); - db + let schema = Schema::new(DbBackend::Sqlite); + let stmt: TableCreateStatement = schema.create_table_from_entity(candidate::Entity); + let stmt2: TableCreateStatement = schema.create_table_from_entity(admin::Entity); + let stmt3: TableCreateStatement = schema.create_table_from_entity(session::Entity); + let stmt4: TableCreateStatement = schema.create_table_from_entity(parent::Entity); + db.execute(db.get_database_backend().build(&stmt)).await.unwrap(); + db.execute(db.get_database_backend().build(&stmt2)).await.unwrap(); + db.execute(db.get_database_backend().build(&stmt3)).await.unwrap(); + db.execute(db.get_database_backend().build(&stmt4)).await.unwrap(); + db } #[cfg(test)]