use std::str::FromStr; use axum::{ extract::{Path, State}, response::IntoResponse, Json, }; use axum_extra::{headers::Authorization, routing::Resource, TypedHeader}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use time::OffsetDateTime; use uuid::Uuid; use crate::{auth::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 UserSchema { pub uuid: Uuid, pub name: String, pub email: String, pub session_epoch: OffsetDateTime, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } impl Default for UserSchema { fn default() -> Self { let now = time::OffsetDateTime::now_utc(); Self { uuid: Default::default(), name: Default::default(), email: Default::default(), session_epoch: now, created_at: now, updated_at: now, } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RegisterSchema { pub name: String, pub email: String, pub password: String, } pub async fn create( State(state): State, Json(RegisterSchema { name, email, password, }): Json, ) -> impl IntoResponse { email_address::EmailAddress::from_str(&email)?; let exists: Option = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1 LIMIT 1)") .bind(email.to_ascii_lowercase()) .fetch_one(&state.pool) .await?; if exists.is_some_and(|b| b) { return Err(Error::EmailExists); } let mut transaction = state.pool.begin().await?; let user = sqlx::query_as!( UserSchema, "INSERT INTO users (name,email) VALUES ($1, $2) RETURNING *", name, email.to_ascii_lowercase(), ) .fetch_one(&mut *transaction) .await?; let (parts, _) = crate::auth::create( State(state), TypedHeader(Authorization::basic(&user.uuid.to_string(), &password)), ) .await? .into_response() .into_parts(); transaction.commit().await?; Ok((parts, 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!( UserSchema, "SELECT * FROM users WHERE uuid = $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 UUID: uuid::Uuid = uuid::uuid!("4c14f795-86f0-4361-a02f-0edb966fb145"); #[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 user = UserSchema { uuid: UUID, name: "Arthur Dent".to_string(), email: "adent@earth.sol".to_string(), ..Default::default() }; let request = Request::builder() .uri(format!("/users/{UUID}")) .header(COOKIE, HeaderValue::try_from(AccessClaims::new(UUID))?) .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 UserSchema { uuid, name, email, .. } = serde_json::from_slice(&body_bytes)?; assert_eq!(user.uuid, uuid); 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/{UUID}")) .header(COOKIE, HeaderValue::try_from(AccessClaims::new(UUID))?) .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/{UUID}")) .header( COOKIE, HeaderValue::try_from(AccessClaims::new(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/{UUID}")) .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/{UUID}")) .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 = RegisterSchema { name: "Arthur Dent".to_string(), email: "adent@earth.sol".to_string(), password: "solongandthanksforallthefish".to_string(), }; 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 UserSchema { 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 = RegisterSchema { name: "Arthur Dent".to_string(), email: "adent@earth.sol".to_string(), password: "solongandthanksforallthefish".to_string(), }; 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(()) } }