diff options
author | Toby Vincent <tobyv@tobyvin.dev> | 2024-04-11 22:44:07 -0500 |
---|---|---|
committer | Toby Vincent <tobyv@tobyvin.dev> | 2024-04-11 23:51:19 -0500 |
commit | a20f3667a88affa0498e564cea17e9e795162bb8 (patch) | |
tree | 29797241f1bce86193a733f8cfdc49121f91ddad /src/api/users.rs | |
parent | 61acc1f6e418d1d6947658424115af994a2689dd (diff) |
feat: impl auth and modularize routers
Diffstat (limited to 'src/api/users.rs')
-rw-r--r-- | src/api/users.rs | 302 |
1 files changed, 302 insertions, 0 deletions
diff --git a/src/api/users.rs b/src/api/users.rs new file mode 100644 index 0000000..3eb7e89 --- /dev/null +++ b/src/api/users.rs @@ -0,0 +1,302 @@ +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, Error}; + +pub fn router() -> Resource<AppState> { + 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<AppState>, + Json(RegisterSchema { + name, + email, + password, + }): Json<RegisterSchema>, +) -> impl IntoResponse { + email_address::EmailAddress::from_str(&email)?; + + let exists: Option<bool> = + 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<Uuid>, + State(state): State<AppState>, + AccessClaims { sub, .. }: AccessClaims, +) -> Result<impl IntoResponse, Error> { + 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::UNAUTHORIZED, 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(()) + } +} |