use std::str::FromStr; use axum::{ extract::{Path, State}, response::IntoResponse, Json, }; use axum_extra::routing::Resource; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use time::OffsetDateTime; use uuid::Uuid; use crate::{ auth::{credentials::Credential, AccessClaims}, state::AppState, }; use super::error::Error; pub fn router() -> Resource { Resource::named("users").create(create).show(show) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, FromRow)] #[serde(rename_all = "camelCase")] pub struct User { pub id: Uuid, pub name: String, pub email: String, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Registration { pub name: String, pub email: String, pub password: String, } pub async fn create( State(state): State, Json(Registration { name, email, password, }): Json, ) -> impl IntoResponse { email_address::EmailAddress::from_str(&email)?; let exists: Option = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM user_ WHERE email = $1 LIMIT 1)", email.to_ascii_lowercase() ) .fetch_one(&state.pool) .await?; if exists.is_some_and(|b| b) { return Err(Error::EmailExists); } // TODO: Move this into a micro service, possibly behind a feature flag. let (status, (access, refresh)) = crate::auth::credentials::create(State(state.clone()), Json(Credential { password })) .await?; let user = sqlx::query_as!( User, "INSERT INTO user_ (id,name,email) VALUES ($1, $2, $3) RETURNING *", refresh.sub, name, email.to_ascii_lowercase(), ) .fetch_one(&state.pool) .await?; Ok((status, access, refresh, Json(user))) } pub async fn show( Path(uuid): Path, State(state): State, AccessClaims { sub, .. }: AccessClaims, ) -> Result { if uuid != sub { return Err(Error::InvalidToken); } sqlx::query_as!(User, "SELECT * FROM user_ WHERE id = $1 LIMIT 1", sub) .fetch_optional(&state.pool) .await? .ok_or_else(|| Error::UserNotFound) .map(Json) } #[cfg(test)] mod tests { use super::*; use axum::{ body::Body, http::{ header::{CONTENT_TYPE, COOKIE}, HeaderValue, Request, StatusCode, }, Router, }; use http_body_util::BodyExt; use sqlx::PgPool; use tower::ServiceExt; use crate::{ auth::AccessClaims, tests::{setup_test_env, TestResult}, }; const USER_ID: Uuid = uuid::uuid!("4c14f795-86f0-4361-a02f-0edb966fb145"); const USER_NAME: &str = "Arthur Dent"; const USER_EMAIL: &str = "adent@earth.sol"; const USER_PASSWORD: &str = "solongandthanksforallthefish"; #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] async fn test_uuid_ok(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let request = Request::builder() .uri(format!("/users/{}", USER_ID)) .header(COOKIE, HeaderValue::try_from(AccessClaims::issue(USER_ID))?) .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::OK, response.status()); let body_bytes = response.into_body().collect().await?.to_bytes(); let User { id, name, email, .. } = serde_json::from_slice(&body_bytes)?; assert_eq!(USER_ID, id); assert_eq!(USER_NAME, name); assert_eq!(USER_EMAIL, email); Ok(()) } #[sqlx::test] async fn test_uuid_not_found(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let request = Request::builder() .uri(format!("/users/{}", USER_ID)) .header(COOKIE, HeaderValue::try_from(AccessClaims::issue(USER_ID))?) .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::NOT_FOUND, response.status()); Ok(()) } #[sqlx::test] async fn test_unauthorized_invalid_token_signature(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let request = Request::builder() .uri(format!("/users/{}", USER_ID)) .header( COOKIE, HeaderValue::try_from(AccessClaims::issue(uuid::Uuid::new_v4()))?, ) .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::UNAUTHORIZED, response.status()); Ok(()) } #[sqlx::test] async fn test_unauthorized_invalid_token_format(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let request = Request::builder() .uri(format!("/users/{}", USER_ID)) .header(COOKIE, "token=sadfasdfsdfs") .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::UNPROCESSABLE_ENTITY, response.status()); Ok(()) } #[sqlx::test] async fn test_unauthorized_missing_token(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let request = Request::builder() .uri(format!("/users/{}", USER_ID)) .body(Body::empty())?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::UNAUTHORIZED, response.status()); Ok(()) } #[sqlx::test] async fn test_create_created(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let user = serde_json::json!( { "name": USER_NAME, "email": USER_EMAIL, "password": USER_PASSWORD, }); let request = Request::builder() .uri("/users") .method("POST") .header(CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) .body(Body::from(serde_json::to_vec(&user)?))?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::CREATED, response.status()); let body_bytes = response.into_body().collect().await?.to_bytes(); let User { name, email, .. } = serde_json::from_slice(&body_bytes)?; assert_eq!(USER_NAME, name); assert_eq!(USER_EMAIL, email); Ok(()) } #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))] async fn test_create_conflict(pool: PgPool) -> TestResult { setup_test_env(); let router = Router::new().merge(router()).with_state(AppState { pool }); let user = serde_json::json!( { "name": USER_NAME, "email": USER_EMAIL, "password": USER_PASSWORD, }); let request = Request::builder() .uri("/users") .method("POST") .header(CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) .body(Body::from(serde_json::to_vec(&user)?))?; let response = router.oneshot(request).await?; assert_eq!(StatusCode::CONFLICT, response.status()); Ok(()) } }