From 04d7f872d54c1d4ea2206a29d9ed3b74170113f3 Mon Sep 17 00:00:00 2001 From: realaravinth Date: Fri, 16 Sep 2022 13:23:19 +0530 Subject: [PATCH] feat: auth and account management API --- .github/workflows/linux.yml | 4 +- src/api/mod.rs | 17 ++ src/api/v1/account/mod.rs | 138 ++++++++++++++++ src/api/v1/account/test.rs | 296 ++++++++++++++++++++++++++++++++++ src/api/v1/auth.rs | 71 ++++++++ src/api/v1/mod.rs | 45 ++++++ src/api/v1/routes.rs | 123 ++++++++++++++ src/api/v1/tests/auth.rs | 174 ++++++++++++++++++++ src/api/v1/tests/mod.rs | 18 +++ src/api/v1/tests/protected.rs | 70 ++++++++ src/tests.rs | 163 +++++++++++++++++-- 11 files changed, 1100 insertions(+), 19 deletions(-) create mode 100644 src/api/mod.rs create mode 100644 src/api/v1/account/mod.rs create mode 100644 src/api/v1/account/test.rs create mode 100644 src/api/v1/auth.rs create mode 100644 src/api/v1/mod.rs create mode 100644 src/api/v1/routes.rs create mode 100644 src/api/v1/tests/auth.rs create mode 100644 src/api/v1/tests/mod.rs create mode 100644 src/api/v1/tests/protected.rs diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 8f7c767..d4619b6 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -83,11 +83,11 @@ jobs: run: make docker - name: publish docker images - if: (github.ref == 'refs/heads/master' || github.event_name == 'push') && github.repository == 'realaravinth/librepages' + if: (github.ref == 'refs/heads/master' && github.event_name == 'push') && github.repository == 'realaravinth/librepages' run: make docker-publish - name: publish bins - if: (github.ref == 'refs/heads/master' || github.event_name == 'push') && github.repository == 'realaravinth/librepages' + if: (github.ref == 'refs/heads/master' && github.event_name == 'push') && github.repository == 'realaravinth/librepages' run: ./scripts/bin-publish.sh publish master latest $DUMBSERVE_PASSWORD env: DUMBSERVE_PASSWORD: ${{ secrets.DUMBSERVE_PASSWORD }} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..5aa8f74 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2022 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +pub mod v1; diff --git a/src/api/v1/account/mod.rs b/src/api/v1/account/mod.rs new file mode 100644 index 0000000..d4a8a4e --- /dev/null +++ b/src/api/v1/account/mod.rs @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2021 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +use actix_identity::Identity; +use actix_web::{web, HttpResponse, Responder}; +use serde::{Deserialize, Serialize}; + +use crate::ctx::api::v1::account::*; +use crate::ctx::api::v1::auth::Password; +use crate::errors::*; +use crate::AppCtx; + +#[cfg(test)] +pub mod test; + +pub use super::auth; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AccountCheckPayload { + pub val: String, +} + +pub fn services(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(username_exists); + cfg.service(set_username); + cfg.service(email_exists); + cfg.service(set_email); + cfg.service(delete_account); + cfg.service(update_user_password); +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Email { + pub email: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Username { + pub username: String, +} + +/// update username +#[actix_web_codegen_const_routes::post( + path = "crate::V1_API_ROUTES.account.update_username", + wrap = "super::get_auth_middleware()" +)] +async fn set_username( + id: Identity, + payload: web::Json, + ctx: AppCtx, +) -> ServiceResult { + let username = id.identity().unwrap(); + + let new_name = ctx.update_username(&username, &payload.username).await?; + + id.forget(); + id.remember(new_name); + + Ok(HttpResponse::Ok()) +} + +#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.account.username_exists")] +async fn username_exists( + payload: web::Json, + ctx: AppCtx, +) -> ServiceResult { + Ok(HttpResponse::Ok().json(ctx.username_exists(&payload.val).await?)) +} + +#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.account.email_exists")] +pub async fn email_exists( + payload: web::Json, + ctx: AppCtx, +) -> ServiceResult { + Ok(HttpResponse::Ok().json(ctx.email_exists(&payload.val).await?)) +} + +/// update email +#[actix_web_codegen_const_routes::post( + path = "crate::V1_API_ROUTES.account.update_email", + wrap = "super::get_auth_middleware()" +)] +async fn set_email( + id: Identity, + payload: web::Json, + ctx: AppCtx, +) -> ServiceResult { + let username = id.identity().unwrap(); + ctx.set_email(&username, &payload.email).await?; + Ok(HttpResponse::Ok()) +} + +#[actix_web_codegen_const_routes::post( + path = "crate::V1_API_ROUTES.account.delete", + wrap = "super::get_auth_middleware()" +)] +async fn delete_account( + id: Identity, + payload: web::Json, + ctx: AppCtx, +) -> ServiceResult { + let username = id.identity().unwrap(); + + ctx.delete_user(&username, &payload.password).await?; + id.forget(); + Ok(HttpResponse::Ok()) +} + +#[actix_web_codegen_const_routes::post( + path = "crate::V1_API_ROUTES.account.update_password", + wrap = "super::get_auth_middleware()" +)] +async fn update_user_password( + id: Identity, + ctx: AppCtx, + + payload: web::Json, +) -> ServiceResult { + let username = id.identity().unwrap(); + let payload = payload.into_inner(); + ctx.change_password(&username, &payload).await?; + + Ok(HttpResponse::Ok()) +} diff --git a/src/api/v1/account/test.rs b/src/api/v1/account/test.rs new file mode 100644 index 0000000..c0ff43d --- /dev/null +++ b/src/api/v1/account/test.rs @@ -0,0 +1,296 @@ +/* +* Copyright (C) 2021 Aravinth Manivannan +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see . +*/ +use std::sync::Arc; + +use actix_web::http::StatusCode; +use actix_web::test; + +use super::*; +use crate::api::v1::ROUTES; +use crate::ctx::api::v1::auth::Password; +use crate::ctx::Ctx; +use crate::*; + +#[actix_rt::test] +async fn postgrest_account_works() { + let (_, ctx) = crate::tests::get_ctx().await; + uname_email_exists_works(ctx.clone()).await; + email_udpate_password_validation_del_userworks(ctx.clone()).await; + username_update_works(ctx.clone()).await; + update_password_works(ctx.clone()).await; +} + +async fn uname_email_exists_works(ctx: Arc) { + const NAME: &str = "testuserexists"; + const PASSWORD: &str = "longpasswordasdfa2"; + const EMAIL: &str = "testuserexists2@a.com"; + + let _ = ctx.delete_user(NAME, PASSWORD).await; + + let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(ctx).await; + + let mut payload = AccountCheckPayload { val: NAME.into() }; + + let user_exists_resp = test::call_service( + &app, + post_request!(&payload, ROUTES.account.username_exists) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + assert_eq!(user_exists_resp.status(), StatusCode::OK); + let mut resp: AccountCheckResp = test::read_body_json(user_exists_resp).await; + assert!(resp.exists); + + payload.val = PASSWORD.into(); + + let user_doesnt_exist = test::call_service( + &app, + post_request!(&payload, ROUTES.account.username_exists) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + assert_eq!(user_doesnt_exist.status(), StatusCode::OK); + resp = test::read_body_json(user_doesnt_exist).await; + assert!(!resp.exists); + + let email_doesnt_exist = test::call_service( + &app, + post_request!(&payload, ROUTES.account.email_exists) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + assert_eq!(email_doesnt_exist.status(), StatusCode::OK); + resp = test::read_body_json(email_doesnt_exist).await; + assert!(!resp.exists); + + payload.val = EMAIL.into(); + + let email_exist = test::call_service( + &app, + post_request!(&payload, ROUTES.account.email_exists) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + assert_eq!(email_exist.status(), StatusCode::OK); + resp = test::read_body_json(email_exist).await; + assert!(resp.exists); +} + +async fn email_udpate_password_validation_del_userworks(ctx: Arc) { + const NAME: &str = "testuser2"; + const PASSWORD: &str = "longpassword2"; + const EMAIL: &str = "testuser1@a.com2"; + const NAME2: &str = "eupdauser"; + const EMAIL2: &str = "eupdauser@a.com"; + + let _ = ctx.delete_user(NAME, PASSWORD).await; + let _ = ctx.delete_user(NAME2, PASSWORD).await; + + let _ = ctx.register_and_signin(NAME2, EMAIL2, PASSWORD).await; + let (_creds, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(ctx).await; + + // update email + let mut email_payload = Email { + email: EMAIL.into(), + }; + let email_update_resp = test::call_service( + &app, + post_request!(&email_payload, ROUTES.account.update_email) + //post_request!(&email_payload, EMAIL_UPDATE) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + assert_eq!(email_update_resp.status(), StatusCode::OK); + + // check duplicate email while duplicate email + email_payload.email = EMAIL2.into(); + ctx.bad_post_req_test( + NAME, + PASSWORD, + ROUTES.account.update_email, + &email_payload, + ServiceError::EmailTaken, + ) + .await; + + // wrong password while deleteing account + let mut payload = Password { + password: NAME.into(), + }; + ctx.bad_post_req_test( + NAME, + PASSWORD, + ROUTES.account.delete, + &payload, + ServiceError::WrongPassword, + ) + .await; + + // delete account + payload.password = PASSWORD.into(); + let delete_user_resp = test::call_service( + &app, + post_request!(&payload, ROUTES.account.delete) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + + assert_eq!(delete_user_resp.status(), StatusCode::OK); + + // try to delete an account that doesn't exist + let account_not_found_resp = test::call_service( + &app, + post_request!(&payload, ROUTES.account.delete) + .cookie(cookies) + .to_request(), + ) + .await; + assert_eq!(account_not_found_resp.status(), StatusCode::NOT_FOUND); + let txt: ErrorToResponse = test::read_body_json(account_not_found_resp).await; + assert_eq!(txt.error, format!("{}", ServiceError::AccountNotFound)); +} + +async fn username_update_works(ctx: Arc) { + const NAME: &str = "testuserupda"; + const EMAIL: &str = "testuserupda@sss.com"; + const EMAIL2: &str = "testuserupda2@sss.com"; + const PASSWORD: &str = "longpassword2"; + const NAME2: &str = "terstusrtds"; + const NAME_CHANGE: &str = "terstusrtdsxx"; + + let _ = futures::join!( + ctx.delete_user(NAME, PASSWORD), + ctx.delete_user(NAME2, PASSWORD), + ctx.delete_user(NAME_CHANGE, PASSWORD) + ); + + let _ = ctx.register_and_signin(NAME2, EMAIL2, PASSWORD).await; + let (_creds, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(ctx).await; + + // update username + let mut username_udpate = Username { + username: NAME_CHANGE.into(), + }; + let username_update_resp = test::call_service( + &app, + post_request!(&username_udpate, ROUTES.account.update_username) + .cookie(cookies) + .to_request(), + ) + .await; + assert_eq!(username_update_resp.status(), StatusCode::OK); + + // check duplicate username with duplicate username + username_udpate.username = NAME2.into(); + ctx.bad_post_req_test( + NAME_CHANGE, + PASSWORD, + ROUTES.account.update_username, + &username_udpate, + ServiceError::UsernameTaken, + ) + .await; +} + +async fn update_password_works(ctx: Arc) { + const NAME: &str = "updatepassuser"; + const PASSWORD: &str = "longpassword2"; + const EMAIL: &str = "updatepassuser@a.com"; + + let _ = ctx.delete_user(NAME, PASSWORD).await; + + let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(ctx).await; + + let new_password = "newpassword"; + + let update_password = ChangePasswordReqest { + password: PASSWORD.into(), + new_password: new_password.into(), + confirm_new_password: PASSWORD.into(), + }; + + let res = ctx.change_password(NAME, &update_password).await; + assert!(res.is_err()); + assert_eq!(res, Err(ServiceError::PasswordsDontMatch)); + + let update_password = ChangePasswordReqest { + password: PASSWORD.into(), + new_password: new_password.into(), + confirm_new_password: new_password.into(), + }; + + assert!(ctx.change_password(NAME, &update_password).await.is_ok()); + + let update_password = ChangePasswordReqest { + password: new_password.into(), + new_password: new_password.into(), + confirm_new_password: PASSWORD.into(), + }; + + ctx.bad_post_req_test( + NAME, + new_password, + ROUTES.account.update_password, + &update_password, + ServiceError::PasswordsDontMatch, + ) + .await; + + let update_password = ChangePasswordReqest { + password: PASSWORD.into(), + new_password: PASSWORD.into(), + confirm_new_password: PASSWORD.into(), + }; + + ctx.bad_post_req_test( + NAME, + new_password, + ROUTES.account.update_password, + &update_password, + ServiceError::WrongPassword, + ) + .await; + + let update_password = ChangePasswordReqest { + password: new_password.into(), + new_password: PASSWORD.into(), + confirm_new_password: PASSWORD.into(), + }; + + let update_password_resp = test::call_service( + &app, + post_request!(&update_password, ROUTES.account.update_password) + .cookie(cookies) + .to_request(), + ) + .await; + assert_eq!(update_password_resp.status(), StatusCode::OK); +} diff --git a/src/api/v1/auth.rs b/src/api/v1/auth.rs new file mode 100644 index 0000000..8b2c1eb --- /dev/null +++ b/src/api/v1/auth.rs @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2022 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +use crate::ctx::api::v1::auth::{Login, Register}; +use actix_identity::Identity; +use actix_web::http::header; +use actix_web::{web, HttpResponse, Responder}; + +use super::RedirectQuery; +use crate::errors::*; +use crate::AppCtx; + +pub fn services(cfg: &mut web::ServiceConfig) { + cfg.service(register); + cfg.service(login); + cfg.service(signout); +} +#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.auth.register")] +async fn register(payload: web::Json, ctx: AppCtx) -> ServiceResult { + ctx.register(&payload).await?; + Ok(HttpResponse::Ok()) +} + +#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.auth.login")] +async fn login( + id: Identity, + payload: web::Json, + query: web::Query, + ctx: AppCtx, +) -> ServiceResult { + let payload = payload.into_inner(); + let username = ctx.login(&payload).await?; + id.remember(username); + let query = query.into_inner(); + if let Some(redirect_to) = query.redirect_to { + Ok(HttpResponse::Found() + .insert_header((header::LOCATION, redirect_to)) + .finish()) + } else { + Ok(HttpResponse::Ok().into()) + } +} + +#[actix_web_codegen_const_routes::get( + path = "crate::V1_API_ROUTES.auth.logout", + wrap = "super::get_auth_middleware()" +)] +async fn signout(id: Identity) -> impl Responder { + use actix_auth_middleware::GetLoginRoute; + + if id.identity().is_some() { + id.forget(); + } + HttpResponse::Found() + .append_header((header::LOCATION, crate::V1_API_ROUTES.get_login_route(None))) + .finish() +} diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs new file mode 100644 index 0000000..6d891c0 --- /dev/null +++ b/src/api/v1/mod.rs @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2022 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use actix_auth_middleware::Authentication; +use actix_web::web::ServiceConfig; +use serde::Deserialize; + +pub mod account; +pub mod auth; +pub mod routes; + +pub use routes::ROUTES; + +pub fn services(cfg: &mut ServiceConfig) { + auth::services(cfg); + account::services(cfg); + crate::meta::services(cfg); + crate::deploy::services(cfg); + crate::serve::services(cfg); +} + +#[derive(Deserialize)] +pub struct RedirectQuery { + pub redirect_to: Option, +} + +pub fn get_auth_middleware() -> Authentication { + Authentication::with_identity(ROUTES) +} + +#[cfg(test)] +mod tests; diff --git a/src/api/v1/routes.rs b/src/api/v1/routes.rs new file mode 100644 index 0000000..8d50479 --- /dev/null +++ b/src/api/v1/routes.rs @@ -0,0 +1,123 @@ +/* +* Copyright (C) 2022 Aravinth Manivannan +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see . +*/ +//! V1 API Routes +use actix_auth_middleware::GetLoginRoute; + +use crate::deploy::routes::Deploy; +use crate::meta::routes::Meta; +use crate::serve::routes::Serve; + +/// constant [Routes](Routes) instance +pub const ROUTES: Routes = Routes::new(); + +/// Authentication routes +pub struct Auth { + /// logout route + pub logout: &'static str, + /// login route + pub login: &'static str, + /// registration route + pub register: &'static str, +} +impl Auth { + /// create new instance of Authentication route + pub const fn new() -> Auth { + let login = "/api/v1/signin"; + let logout = "/api/v1/logout"; + let register = "/api/v1/signup"; + Auth { + logout, + login, + register, + } + } +} + +/// Account management routes +pub struct Account { + /// delete account route + pub delete: &'static str, + /// route to check if an email exists + pub email_exists: &'static str, + /// route to update a user's email + pub update_email: &'static str, + /// route to update password + pub update_password: &'static str, + /// route to check if a username is already registered + pub username_exists: &'static str, + /// route to change username + pub update_username: &'static str, +} + +impl Account { + /// create a new instance of [Account][Account] routes + pub const fn new() -> Account { + let delete = "/api/v1/account/delete"; + let email_exists = "/api/v1/account/email/exists"; + let username_exists = "/api/v1/account/username/exists"; + let update_username = "/api/v1/account/username/update"; + let update_email = "/api/v1/account/email/update"; + let update_password = "/api/v1/account/password/update"; + Account { + delete, + email_exists, + update_email, + update_password, + username_exists, + update_username, + } + } +} + +/// Top-level routes data structure for V1 AP1 +pub struct Routes { + /// Authentication routes + pub auth: Auth, + /// Account routes + pub account: Account, + /// Meta routes + pub meta: Meta, + pub deploy: Deploy, + pub serve: Serve, +} + +impl Routes { + /// create new instance of Routes + const fn new() -> Routes { + Routes { + auth: Auth::new(), + account: Account::new(), + meta: Meta::new(), + deploy: Deploy::new(), + serve: Serve::new(), + } + } +} + +impl GetLoginRoute for Routes { + fn get_login_route(&self, src: Option<&str>) -> String { + if let Some(redirect_to) = src { + format!( + "{}?redirect_to={}", + self.auth.login, + urlencoding::encode(redirect_to) + ) + } else { + self.auth.register.to_string() + } + } +} diff --git a/src/api/v1/tests/auth.rs b/src/api/v1/tests/auth.rs new file mode 100644 index 0000000..7740c1d --- /dev/null +++ b/src/api/v1/tests/auth.rs @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2021 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +use actix_auth_middleware::GetLoginRoute; +use actix_web::http::{header, StatusCode}; +use actix_web::test; + +use crate::api::v1::ROUTES; +use crate::ctx::api::v1::auth::{Login, Register}; +use crate::ctx::ArcCtx; +use crate::errors::*; +use crate::*; + +#[actix_rt::test] +async fn postgrest_auth_works() { + let (_, ctx) = crate::tests::get_ctx().await; + auth_works(ctx.clone()).await; + serverside_password_validation_works(ctx).await; +} + +async fn auth_works(ctx: ArcCtx) { + const NAME: &str = "testuserfoo"; + const PASSWORD: &str = "longpassword"; + const EMAIL: &str = "testuser1foo@a.com"; + + let _ = ctx.delete_user(NAME, PASSWORD).await; + let app = get_app!(ctx).await; + + // 1. Register and signin + let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + + // Sign in with email + ctx.signin_test(EMAIL, PASSWORD).await; + + // 2. check if duplicate username is allowed + let mut msg = Register { + username: NAME.into(), + password: PASSWORD.into(), + confirm_password: PASSWORD.into(), + email: EMAIL.into(), + }; + + msg.username = format!("asdfasd{}", msg.username); + ctx.bad_post_req_test( + NAME, + PASSWORD, + ROUTES.auth.register, + &msg, + ServiceError::EmailTaken, + ) + .await; + + msg.email = format!("asdfasd{}", msg.email); + msg.username = NAME.into(); + ctx.bad_post_req_test( + NAME, + PASSWORD, + ROUTES.auth.register, + &msg, + ServiceError::UsernameTaken, + ) + .await; + + // 3. sigining in with non-existent user + let mut creds = Login { + login: "nonexistantuser".into(), + password: msg.password.clone(), + }; + ctx.bad_post_req_test( + NAME, + PASSWORD, + ROUTES.auth.login, + &creds, + ServiceError::AccountNotFound, + ) + .await; + + creds.login = "nonexistantuser@example.com".into(); + ctx.bad_post_req_test( + NAME, + PASSWORD, + ROUTES.auth.login, + &creds, + ServiceError::AccountNotFound, + ) + .await; + + // 4. trying to signin with wrong password + creds.login = NAME.into(); + creds.password = NAME.into(); + + ctx.bad_post_req_test( + NAME, + PASSWORD, + ROUTES.auth.login, + &creds, + ServiceError::WrongPassword, + ) + .await; + + // 5. signout + let signout_resp = test::call_service( + &app, + test::TestRequest::get() + .uri(ROUTES.auth.logout) + .cookie(cookies) + .to_request(), + ) + .await; + assert_eq!(signout_resp.status(), StatusCode::FOUND); + let headers = signout_resp.headers(); + assert_eq!( + headers.get(header::LOCATION).unwrap(), + &crate::V1_API_ROUTES.get_login_route(None) + ); + + let creds = Login { + login: NAME.into(), + password: PASSWORD.into(), + }; + + //6. sigin with redirect URL set + let redirect_to = ROUTES.auth.logout; + let resp = test::call_service( + &app, + post_request!(&creds, &ROUTES.get_login_route(Some(redirect_to))).to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::FOUND); + let headers = resp.headers(); + assert_eq!(headers.get(header::LOCATION).unwrap(), &redirect_to); +} + +async fn serverside_password_validation_works(ctx: ArcCtx) { + const NAME: &str = "testuser542"; + const PASSWORD: &str = "longpassword2"; + const EMAIL: &str = "testuser542@example.com"; + + let _ = ctx.delete_user(NAME, PASSWORD).await; + + let app = get_app!(ctx).await; + + // checking to see if server-side password validation (password == password_config) + // works + let register_msg = Register { + username: NAME.into(), + password: PASSWORD.into(), + confirm_password: NAME.into(), + email: EMAIL.into(), + }; + let resp = test::call_service( + &app, + post_request!(®ister_msg, ROUTES.auth.register).to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let txt: ErrorToResponse = test::read_body_json(resp).await; + assert_eq!(txt.error, format!("{}", ServiceError::PasswordsDontMatch)); +} diff --git a/src/api/v1/tests/mod.rs b/src/api/v1/tests/mod.rs new file mode 100644 index 0000000..09f3aa9 --- /dev/null +++ b/src/api/v1/tests/mod.rs @@ -0,0 +1,18 @@ +/* +* Copyright (C) 2021 Aravinth Manivannan +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see . +*/ +mod auth; +mod protected; diff --git a/src/api/v1/tests/protected.rs b/src/api/v1/tests/protected.rs new file mode 100644 index 0000000..f12614c --- /dev/null +++ b/src/api/v1/tests/protected.rs @@ -0,0 +1,70 @@ +/* +* Copyright (C) 2021 Aravinth Manivannan +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see . +*/ + +use actix_web::http::StatusCode; +use actix_web::test; + +use crate::ctx::ArcCtx; +//use crate::pages::PAGES; +use crate::*; + +use crate::tests::*; + +#[actix_rt::test] +async fn postgrest_protected_routes_work() { + let (_, ctx) = get_ctx().await; + protected_routes_work(ctx.clone()).await +} + +async fn protected_routes_work(ctx: ArcCtx) { + const NAME: &str = "testuser619"; + const PASSWORD: &str = "longpassword2"; + const EMAIL: &str = "testuser119@a.com2"; + + let _post_protected_urls = [ + "/api/v1/account/secret/", + "/api/v1/account/email/", + "/api/v1/account/delete", + ]; + + let get_protected_urls = [ + V1_API_ROUTES.auth.logout, + // PAGES.auth.logout, + // PAGES.home, + ]; + + let _ = ctx.delete_user(NAME, PASSWORD).await; + + let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(ctx).await; + + for url in get_protected_urls.iter() { + let resp = get_request!(&app, url); + assert_eq!(resp.status(), StatusCode::FOUND); + + let authenticated_resp = get_request!(&app, url, cookies.clone()); + + println!("{url}"); + if url == &V1_API_ROUTES.auth.logout { + // || url == &PAGES.auth.logout { + assert_eq!(authenticated_resp.status(), StatusCode::FOUND); + } else { + assert_eq!(authenticated_resp.status(), StatusCode::OK); + } + } +} diff --git a/src/tests.rs b/src/tests.rs index a6367c1..fd3d420 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -17,13 +17,23 @@ use std::path::Path; use std::sync::Arc; +use actix_web::{ + body::{BoxBody, EitherBody}, + dev::ServiceResponse, + error::ResponseError, + http::StatusCode, +}; use mktemp::Temp; +use serde::Serialize; +use crate::ctx::api::v1::auth::{Login, Register}; use crate::ctx::Ctx; +use crate::errors::*; use crate::page::Page; use crate::settings::Settings; +use crate::*; -pub async fn get_data() -> (Temp, Arc) { +pub async fn get_ctx() -> (Temp, Arc) { // mktemp::Temp is returned because the temp directory created // is removed once the variable goes out of scope let mut settings = Settings::new().unwrap(); @@ -46,6 +56,7 @@ pub async fn get_data() -> (Temp, Arc) { } settings.pages = pages; + settings.init(); println!("[log] Initialzing settings again with test config"); settings.init(); @@ -58,18 +69,20 @@ pub struct FORM; #[macro_export] macro_rules! post_request { ($uri:expr) => { - test::TestRequest::post().uri($uri) + actix_web::test::TestRequest::post().uri($uri) }; ($serializable:expr, $uri:expr) => { - test::TestRequest::post() + actix_web::test::TestRequest::post() .uri($uri) .insert_header((actix_web::http::header::CONTENT_TYPE, "application/json")) .set_payload(serde_json::to_string($serializable).unwrap()) }; ($serializable:expr, $uri:expr, FORM) => { - test::TestRequest::post().uri($uri).set_form($serializable) + actix_web::test::TestRequest::post() + .uri($uri) + .set_form($serializable) }; } @@ -111,20 +124,17 @@ macro_rules! delete_request { #[macro_export] macro_rules! get_app { - ("APP") => { - actix_web::App::new() - .app_data($crate::get_json_err()) - .wrap(actix_web::middleware::NormalizePath::new( - actix_web::middleware::TrailingSlash::Trim, - )) - .configure($crate::routes::services) - }; - - // ($settings:ident) => { - // test::init_service(get_app!("APP", $settings)) - // }; ($ctx:expr) => { - test::init_service(get_app!("APP").app_data($crate::WebData::new($ctx.clone()))) + actix_web::test::init_service( + actix_web::App::new() + .app_data($crate::get_json_err()) + .wrap($crate::get_identity_service(&$ctx.settings)) + .wrap(actix_web::middleware::NormalizePath::new( + actix_web::middleware::TrailingSlash::Trim, + )) + .configure($crate::routes::services) + .app_data($crate::WebData::new($ctx.clone())), + ) }; } @@ -149,3 +159,122 @@ macro_rules! check_status { } }; } + +#[macro_export] +macro_rules! get_cookie { + ($resp:expr) => { + $resp.response().cookies().next().unwrap().to_owned() + }; +} + +impl Ctx { + /// register and signin utility + pub async fn register_and_signin( + &self, + name: &str, + email: &str, + password: &str, + ) -> (Login, ServiceResponse>) { + self.register_test(name, email, password).await; + self.signin_test(name, password).await + } + + pub fn to_arc(&self) -> Arc { + Arc::new(self.clone()) + } + + /// register utility + pub async fn register_test(&self, name: &str, email: &str, password: &str) { + let app = get_app!(self.to_arc()).await; + + // 1. Register + let msg = Register { + username: name.into(), + password: password.into(), + confirm_password: password.into(), + email: email.into(), + }; + println!("{:?}", msg); + let resp = actix_web::test::call_service( + &app, + post_request!(&msg, crate::V1_API_ROUTES.auth.register).to_request(), + ) + .await; + // let resp_err: ErrorToResponse = actix_web::test::read_body_json(resp).await; + // panic!("{}", resp_err.error); + assert_eq!(resp.status(), StatusCode::OK); + } + + /// signin util + pub async fn signin_test( + &self, + + name: &str, + password: &str, + ) -> (Login, ServiceResponse>) { + let app = get_app!(self.to_arc()).await; + + // 2. signin + let creds = Login { + login: name.into(), + password: password.into(), + }; + let signin_resp = actix_web::test::call_service( + &app, + post_request!(&creds, V1_API_ROUTES.auth.login).to_request(), + ) + .await; + assert_eq!(signin_resp.status(), StatusCode::OK); + (creds, signin_resp) + } + + /// pub duplicate test + pub async fn bad_post_req_test( + &self, + + name: &str, + password: &str, + url: &str, + payload: &T, + err: ServiceError, + ) { + let (_, signin_resp) = self.signin_test(name, password).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(self.to_arc()).await; + + let resp = actix_web::test::call_service( + &app, + post_request!(&payload, url) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + assert_eq!(resp.status(), err.status_code()); + let resp_err: ErrorToResponse = actix_web::test::read_body_json(resp).await; + //println!("{}", txt.error); + assert_eq!(resp_err.error, format!("{}", err)); + } + + /// bad post req test without payload + pub async fn bad_post_req_test_witout_payload( + &self, + name: &str, + password: &str, + url: &str, + err: ServiceError, + ) { + let (_, signin_resp) = self.signin_test(name, password).await; + let app = get_app!(self.to_arc()).await; + let cookies = get_cookie!(signin_resp); + + let resp = actix_web::test::call_service( + &app, + post_request!(url).cookie(cookies.clone()).to_request(), + ) + .await; + assert_eq!(resp.status(), err.status_code()); + let resp_err: ErrorToResponse = actix_web::test::read_body_json(resp).await; + //println!("{}", resp_err.error); + assert_eq!(resp_err.error, format!("{}", err)); + } +}