feat: add login test

This commit is contained in:
nathan 2023-03-12 19:31:55 +08:00
parent 508961d07d
commit 2429a41b2f
6 changed files with 194 additions and 41 deletions

View File

@ -1,19 +1,58 @@
{ {
"db": "PostgreSQL", "db": "PostgreSQL",
"e8c487b4314c267f6da2667b95f6c8003fabc2461c10df2d6d39d081e74e167f": { "1775f308cda63b61c0aaf4d8d1c7cf00f6bff5041e821575f65867c32d42f1b0": {
"query": "\n INSERT INTO user (uid, username, password, email,create_time)\n VALUES ($1, $2, $3, $4, $5)\n ",
"describe": { "describe": {
"columns": [], "columns": [],
"nullable": [],
"parameters": { "parameters": {
"Left": [ "Left": [
"Uuid", "Uuid",
"Text", "Text",
"Text", "Text",
"Text", "Timestamptz",
"Timestamptz" "Text"
] ]
}, }
"nullable": [] },
} "query": "\n INSERT INTO users (uid, email, username, create_time, password)\n VALUES ($1, $2, $3, $4, $5)\n "
},
"b188bb8915fa0fc8c5dfefa27f1b086b839633c3c4dc6e4991b43a608d46a170": {
"describe": {
"columns": [
{
"name": "uid",
"ordinal": 0,
"type_info": "Uuid"
},
{
"name": "password",
"ordinal": 1,
"type_info": "Text"
}
],
"nullable": [
false,
false
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "\n SELECT uid, password\n FROM users\n WHERE email = $1\n "
},
"b754835f0a1543fc32acbfee436f775e542036888e7a2051622efa435debb318": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Text",
"Uuid"
]
}
},
"query": "\n UPDATE users\n SET password= $1\n WHERE uid = $2\n "
} }
} }

View File

@ -1,5 +1,5 @@
use crate::component::auth::{ use crate::component::auth::{
register_user, InputParamsError, LoginRequest, RegisterRequestParams, login, register, InputParamsError, LoginRequest, RegisterRequestParams,
}; };
use crate::domain::{UserEmail, UserName, UserPassword}; use crate::domain::{UserEmail, UserName, UserPassword};
use crate::state::State; use crate::state::State;
@ -10,21 +10,36 @@ use actix_web::{web, HttpResponse, Scope};
pub fn user_scope() -> Scope { pub fn user_scope() -> Scope {
web::scope("/api/user") web::scope("/api/user")
.service(web::resource("/login").route(web::post().to(login))) .service(web::resource("/login").route(web::post().to(login_handler)))
.service(web::resource("/logout").route(web::get().to(logout))) .service(web::resource("/logout").route(web::get().to(logout_handler)))
.service(web::resource("/register").route(web::post().to(register))) .service(web::resource("/register").route(web::post().to(register_handler)))
} }
async fn login(id: Identity, req: Data<LoginRequest>, state: Data<State>) -> Result<HttpResponse> { async fn login_handler(req: Json<LoginRequest>, state: Data<State>) -> Result<HttpResponse> {
todo!() let params = req.into_inner();
let email = UserEmail::parse(params.email)
.map_err(|e| InputParamsError::InvalidEmail(e))?
.0;
let password = UserPassword::parse(params.password)
.map_err(|_| InputParamsError::InvalidPassword)?
.0;
let resp = login(state.pg_pool.clone(), state.cache.clone(), email, password).await?;
Ok(HttpResponse::Ok().json(resp))
} }
async fn logout(payload: Payload, id: Identity, state: Data<State>) -> Result<HttpResponse> { async fn logout_handler(
payload: Payload,
id: Identity,
state: Data<State>,
) -> Result<HttpResponse> {
todo!() todo!()
} }
#[tracing::instrument(level = "debug", skip(state))] #[tracing::instrument(level = "debug", skip(state))]
async fn register(req: Json<RegisterRequestParams>, state: Data<State>) -> Result<HttpResponse> { async fn register_handler(
req: Json<RegisterRequestParams>,
state: Data<State>,
) -> Result<HttpResponse> {
let params = req.into_inner(); let params = req.into_inner();
let name = UserName::parse(params.name) let name = UserName::parse(params.name)
.map_err(|e| InputParamsError::InvalidName(e))? .map_err(|e| InputParamsError::InvalidName(e))?
@ -36,7 +51,7 @@ async fn register(req: Json<RegisterRequestParams>, state: Data<State>) -> Resul
.map_err(|_| InputParamsError::InvalidPassword)? .map_err(|_| InputParamsError::InvalidPassword)?
.0; .0;
let resp = register_user( let resp = register(
state.pg_pool.clone(), state.pg_pool.clone(),
state.cache.clone(), state.cache.clone(),
name, name,

View File

@ -1,3 +1,4 @@
use crate::component::auth::AuthError;
use crate::telemetry::spawn_blocking_with_tracing; use crate::telemetry::spawn_blocking_with_tracing;
use anyhow::Context; use anyhow::Context;
use argon2::password_hash::SaltString; use argon2::password_hash::SaltString;
@ -6,7 +7,7 @@ use secrecy::{ExposeSecret, Secret};
use sqlx::PgPool; use sqlx::PgPool;
pub struct Credentials { pub struct Credentials {
pub username: String, pub email: String,
pub password: Secret<String>, pub password: Secret<String>,
} }
@ -14,8 +15,8 @@ pub struct Credentials {
pub async fn validate_credentials( pub async fn validate_credentials(
credentials: Credentials, credentials: Credentials,
pool: &PgPool, pool: &PgPool,
) -> Result<uuid::Uuid, anyhow::Error> { ) -> Result<uuid::Uuid, AuthError> {
let mut user_id = None; let mut uid = None;
let mut expected_hash_password = Secret::new( let mut expected_hash_password = Secret::new(
"$argon2id$v=19$m=15000,t=2,p=1$\ "$argon2id$v=19$m=15000,t=2,p=1$\
gZiV/M1gPc22ElAH/Jh1Hw$\ gZiV/M1gPc22ElAH/Jh1Hw$\
@ -23,10 +24,10 @@ pub async fn validate_credentials(
.to_string(), .to_string(),
); );
if let Some((stored_user_id, stored_hash_password)) = if let Some((stored_uid, stored_hash_password)) =
get_stored_credentials(&credentials.username, pool).await? get_stored_credentials(&credentials.email, pool).await?
{ {
user_id = Some(stored_user_id); uid = Some(stored_uid);
expected_hash_password = stored_hash_password; expected_hash_password = stored_hash_password;
} }
@ -36,7 +37,8 @@ pub async fn validate_credentials(
.await .await
.context("Failed to spawn blocking task.")??; .context("Failed to spawn blocking task.")??;
user_id.ok_or_else(|| anyhow::anyhow!("Unknown username.")) uid.ok_or_else(|| anyhow::anyhow!("Unknown email."))
.map_err(AuthError::InvalidCredentials)
} }
#[tracing::instrument(name = "Change password", skip(password, pool))] #[tracing::instrument(name = "Change password", skip(password, pool))]

View File

@ -1,20 +1,56 @@
use crate::component::auth::{compute_hash_password, internal_error, AuthError}; use crate::component::auth::{
compute_hash_password, internal_error, validate_credentials, AuthError, Credentials,
};
use crate::config::env::{domain, jwt_secret}; use crate::config::env::{domain, jwt_secret};
use crate::state::Cache; use crate::state::Cache;
use actix_web::http::header::HeaderValue; use actix_web::http::header::HeaderValue;
use actix_web::{FromRequest, HttpRequest}; use actix_web::{FromRequest, HttpRequest};
use anyhow::Context; use anyhow::{Context, Error};
use chrono::Utc; use chrono::Utc;
use chrono::{Duration, Local}; use chrono::{Duration, Local};
use futures_util::future::{ready, Ready}; use futures_util::future::{ready, Ready};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use secrecy::Secret;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::types::uuid; use sqlx::types::uuid;
use sqlx::{PgPool, Postgres, Transaction}; use sqlx::{PgPool, Postgres, Transaction};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use uuid::Uuid;
pub async fn register_user( pub async fn login(
pg_pool: PgPool,
cache: Arc<RwLock<Cache>>,
email: String,
password: String,
) -> Result<LoginResponse, AuthError> {
let credentials = Credentials {
email,
password: Secret::new(password),
};
match validate_credentials(credentials, &pg_pool).await? {
Ok(uid) => {
let uid = uid.to_string();
let token = Token::create_token(&uid)?.into();
let logged_user = LoggedUser::new(uid);
cache.write().await.authorized(logged_user);
Ok(LoginResponse {
token,
user_id: uid,
})
}
Err(err) => Err(err),
}
}
pub async fn logout(logged_user: LoggedUser, cache: Arc<RwLock<Cache>>) -> Result<(), AuthError> {
cache.write().await.unauthorized(logged_user);
Ok(())
}
pub async fn register(
pg_pool: PgPool, pg_pool: PgPool,
cache: Arc<RwLock<Cache>>, cache: Arc<RwLock<Cache>>,
username: String, username: String,
@ -87,6 +123,12 @@ pub struct LoginRequest {
pub name: String, pub name: String,
} }
#[derive(Default, Serialize, Deserialize, Debug)]
pub struct LoginResponse {
pub token: String,
pub user_id: String,
}
#[derive(Default, Serialize, Deserialize, Debug)] #[derive(Default, Serialize, Deserialize, Debug)]
pub struct RegisterRequestParams { pub struct RegisterRequestParams {
pub email: String, pub email: String,
@ -101,20 +143,18 @@ pub struct RegisterResponse {
#[derive(Debug, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)] #[derive(Debug, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)]
pub struct LoggedUser { pub struct LoggedUser {
pub user_id: String, pub uid: String,
} }
impl std::convert::From<Claim> for LoggedUser { impl std::convert::From<Claim> for LoggedUser {
fn from(c: Claim) -> Self { fn from(c: Claim) -> Self {
Self { Self { uid: c.user_id() }
user_id: c.user_id(),
}
} }
} }
impl LoggedUser { impl LoggedUser {
pub fn new(user_id: String) -> Self { pub fn new(user_id: String) -> Self {
Self { user_id } Self { uid: user_id }
} }
pub fn from_token(token: String) -> Result<Self, AuthError> { pub fn from_token(token: String) -> Result<Self, AuthError> {
@ -123,7 +163,7 @@ impl LoggedUser {
} }
pub fn as_uuid(&self) -> Result<uuid::Uuid, anyhow::Error> { pub fn as_uuid(&self) -> Result<uuid::Uuid, anyhow::Error> {
let uuid = uuid::Uuid::parse_str(&self.user_id).context("Invalid uuid")?; let uuid = uuid::Uuid::parse_str(&self.uid).context("Invalid uuid")?;
Ok(uuid) Ok(uuid)
} }
} }

View File

@ -1,5 +1,6 @@
use crate::test_server::spawn_server; use crate::test_server::spawn_server;
use appflowy_server::component::auth::RegisterResponse; use appflowy_server::component::auth::RegisterResponse;
use reqwest::StatusCode;
#[tokio::test] #[tokio::test]
// curl -X POST --url http://0.0.0.0:8000/api/user/register --header 'content-type: application/json' --data '{"name":"fake name", "email":"fake@appflowy.io", "password":"Fake@123"}' // curl -X POST --url http://0.0.0.0:8000/api/user/register --header 'content-type: application/json' --data '{"name":"fake name", "email":"fake@appflowy.io", "password":"Fake@123"}'
@ -14,3 +15,28 @@ async fn register_success() {
println!("{:?}", response); println!("{:?}", response);
} }
#[tokio::test]
async fn register_with_invalid_password() {
let server = spawn_server().await;
let http_resp = server.register("user 1", "fake@appflowy.io", "123").await;
assert_eq!(http_resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn register_with_invalid_name() {
let server = spawn_server().await;
let http_resp = server
.register("", "fake@appflowy.io", "FakePassword!123")
.await;
assert_eq!(http_resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn register_with_invalid_email() {
let server = spawn_server().await;
let http_resp = server
.register("me", "appflowy.io", "FakePassword!123")
.await;
assert_eq!(http_resp.status(), StatusCode::BAD_REQUEST);
}

View File

@ -9,9 +9,13 @@ use sqlx::{Connection, Executor, PgConnection, PgPool};
// Ensure that the `tracing` stack is only initialised once using `once_cell` // Ensure that the `tracing` stack is only initialised once using `once_cell`
static TRACING: Lazy<()> = Lazy::new(|| { static TRACING: Lazy<()> = Lazy::new(|| {
let default_filter_level = "info".to_string(); let level = "trace".to_string();
let mut filters = vec![];
filters.push(format!("appflowy_server={}", level));
filters.push(format!("hyper={}", level));
let subscriber_name = "test".to_string(); let subscriber_name = "test".to_string();
let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout); let subscriber = get_subscriber(subscriber_name, filters.join(","), std::io::stdout);
init_subscriber(subscriber); init_subscriber(subscriber);
}); });
@ -42,7 +46,6 @@ impl TestServer {
pub async fn spawn_server() -> TestServer { pub async fn spawn_server() -> TestServer {
Lazy::force(&TRACING); Lazy::force(&TRACING);
let database_name = Uuid::new_v4().to_string(); let database_name = Uuid::new_v4().to_string();
let config = { let config = {
let mut config = get_configuration().expect("Failed to read configuration."); let mut config = get_configuration().expect("Failed to read configuration.");
@ -58,18 +61,18 @@ pub async fn spawn_server() -> TestServer {
.await .await
.expect("Failed to build application."); .expect("Failed to build application.");
let port = application.port();
let address = format!("http://localhost:{}", port);
let _ = tokio::spawn(async {
let _ = application.run_until_stopped().await;
});
let api_client = reqwest::Client::builder() let api_client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.cookie_store(true) .cookie_store(true)
.build() .build()
.unwrap(); .unwrap();
let port = application.port();
let address = format!("http://localhost:{}", port);
let _ = tokio::spawn(async {
let _ = application.run_until_stopped();
});
TestServer { TestServer {
state, state,
api_client, api_client,
@ -100,3 +103,31 @@ async fn configure_database(config: &DatabaseSetting) -> PgPool {
connection_pool connection_pool
} }
#[derive(serde::Serialize)]
pub struct TestUser {
name: String,
pub email: String,
pub password: String,
}
impl TestUser {
pub fn generate() -> Self {
Self {
name: "Me".to_string(),
email: "me@appflowy.io".to_string(),
password: "HelloAppFlowy123".to_string(),
}
}
pub async fn register(&self, test_server: &TestServer) {
let url = format!("{}/api/user/register", test_server.address);
test_server
.api_client
.post(&url)
.json(&self)
.send()
.await
.expect("Fail to register user");
}
}