From ea74daca4809e4008dd8d01039db9fff3be659d9 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Wed, 16 Oct 2024 20:14:33 -0400 Subject: Organizational pass on endpoints and routes. --- src/login/app.rs | 4 +- src/login/routes.rs | 97 ------------------------------ src/login/routes/login/mod.rs | 4 ++ src/login/routes/login/post.rs | 51 ++++++++++++++++ src/login/routes/login/test.rs | 129 ++++++++++++++++++++++++++++++++++++++++ src/login/routes/logout/mod.rs | 4 ++ src/login/routes/logout/post.rs | 47 +++++++++++++++ src/login/routes/logout/test.rs | 80 +++++++++++++++++++++++++ src/login/routes/mod.rs | 12 ++++ src/login/routes/test/login.rs | 128 --------------------------------------- src/login/routes/test/logout.rs | 97 ------------------------------ src/login/routes/test/mod.rs | 2 - 12 files changed, 329 insertions(+), 326 deletions(-) delete mode 100644 src/login/routes.rs create mode 100644 src/login/routes/login/mod.rs create mode 100644 src/login/routes/login/post.rs create mode 100644 src/login/routes/login/test.rs create mode 100644 src/login/routes/logout/mod.rs create mode 100644 src/login/routes/logout/post.rs create mode 100644 src/login/routes/logout/test.rs create mode 100644 src/login/routes/mod.rs delete mode 100644 src/login/routes/test/login.rs delete mode 100644 src/login/routes/test/logout.rs delete mode 100644 src/login/routes/test/mod.rs (limited to 'src/login') diff --git a/src/login/app.rs b/src/login/app.rs index bb1419b..b6f7e1c 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -39,6 +39,6 @@ impl<'a> Logins<'a> { #[derive(Debug, thiserror::Error)] #[error(transparent)] pub enum CreateError { - DatabaseError(#[from] sqlx::Error), - PasswordHashError(#[from] password_hash::Error), + Database(#[from] sqlx::Error), + PasswordHash(#[from] password_hash::Error), } diff --git a/src/login/routes.rs b/src/login/routes.rs deleted file mode 100644 index 6579ae6..0000000 --- a/src/login/routes.rs +++ /dev/null @@ -1,97 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::post, - Router, -}; - -use crate::{ - app::App, - clock::RequestedAt, - error::{Internal, Unauthorized}, - login::Password, - token::{app, extract::IdentityToken}, -}; - -#[cfg(test)] -mod test; - -pub fn router() -> Router { - Router::new() - .route("/api/auth/login", post(on_login)) - .route("/api/auth/logout", post(on_logout)) -} - -#[derive(serde::Deserialize)] -struct LoginRequest { - name: String, - password: Password, -} - -async fn on_login( - State(app): State, - RequestedAt(now): RequestedAt, - identity: IdentityToken, - Json(request): Json, -) -> Result<(IdentityToken, StatusCode), LoginError> { - let token = app - .tokens() - .login(&request.name, &request.password, &now) - .await - .map_err(LoginError)?; - let identity = identity.set(token); - Ok((identity, StatusCode::NO_CONTENT)) -} - -#[derive(Debug)] -struct LoginError(app::LoginError); - -impl IntoResponse for LoginError { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - app::LoginError::Rejected => { - // not error::Unauthorized due to differing messaging - (StatusCode::UNAUTHORIZED, "invalid name or password").into_response() - } - other => Internal::from(other).into_response(), - } - } -} - -#[derive(serde::Deserialize)] -struct LogoutRequest {} - -async fn on_logout( - State(app): State, - RequestedAt(now): RequestedAt, - identity: IdentityToken, - // This forces the only valid request to be `{}`, and not the infinite - // variation allowed when there's no body extractor. - Json(LogoutRequest {}): Json, -) -> Result<(IdentityToken, StatusCode), LogoutError> { - if let Some(secret) = identity.secret() { - let (token, _) = app.tokens().validate(&secret, &now).await?; - app.tokens().logout(&token).await?; - } - - let identity = identity.clear(); - Ok((identity, StatusCode::NO_CONTENT)) -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -enum LogoutError { - ValidateError(#[from] app::ValidateError), - DatabaseError(#[from] sqlx::Error), -} - -impl IntoResponse for LogoutError { - fn into_response(self) -> Response { - match self { - Self::ValidateError(app::ValidateError::InvalidToken) => Unauthorized.into_response(), - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/login/routes/login/mod.rs b/src/login/routes/login/mod.rs new file mode 100644 index 0000000..36b384e --- /dev/null +++ b/src/login/routes/login/mod.rs @@ -0,0 +1,4 @@ +pub mod post; + +#[cfg(test)] +mod test; diff --git a/src/login/routes/login/post.rs b/src/login/routes/login/post.rs new file mode 100644 index 0000000..e33acad --- /dev/null +++ b/src/login/routes/login/post.rs @@ -0,0 +1,51 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::Internal, + login::Password, + token::{app, extract::IdentityToken}, +}; + +pub async fn handler( + State(app): State, + RequestedAt(now): RequestedAt, + identity: IdentityToken, + Json(request): Json, +) -> Result<(IdentityToken, StatusCode), Error> { + let token = app + .tokens() + .login(&request.name, &request.password, &now) + .await + .map_err(Error)?; + let identity = identity.set(token); + Ok((identity, StatusCode::NO_CONTENT)) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub name: String, + pub password: Password, +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::LoginError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::LoginError::Rejected => { + // not error::Unauthorized due to differing messaging + (StatusCode::UNAUTHORIZED, "invalid name or password").into_response() + } + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/login/routes/login/test.rs b/src/login/routes/login/test.rs new file mode 100644 index 0000000..d431612 --- /dev/null +++ b/src/login/routes/login/test.rs @@ -0,0 +1,129 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, +}; + +use super::post; +use crate::{test::fixtures, token::app}; + +#[tokio::test] +async fn correct_credentials() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; + + // Call the endpoint + + let identity = fixtures::identity::not_logged_in(); + let logged_in_at = fixtures::now(); + let request = post::Request { + name: name.clone(), + password, + }; + let (identity, status) = + post::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect("logged in with valid credentials"); + + // Verify the return value's basic structure + + assert_eq!(StatusCode::NO_CONTENT, status); + let secret = identity.secret().expect("logged in with valid credentials"); + + // Verify the semantics + + let validated_at = fixtures::now(); + let (_, validated_login) = app + .tokens() + .validate(&secret, &validated_at) + .await + .expect("identity secret is valid"); + + assert_eq!(name, validated_login.name); +} + +#[tokio::test] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::identity::not_logged_in(); + let logged_in_at = fixtures::now(); + let (name, password) = fixtures::login::propose(); + let request = post::Request { + name: name.clone(), + password, + }; + let post::Error(error) = + post::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect_err("logged in with an incorrect password"); + + // Verify the return value's basic structure + + assert!(matches!(error, app::LoginError::Rejected)); +} + +#[tokio::test] +async fn incorrect_password() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let login = fixtures::login::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let logged_in_at = fixtures::now(); + let identity = fixtures::identity::not_logged_in(); + let request = post::Request { + name: login.name, + password: fixtures::login::propose_password(), + }; + let post::Error(error) = + post::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect_err("logged in with an incorrect password"); + + // Verify the return value's basic structure + + assert!(matches!(error, app::LoginError::Rejected)); +} + +#[tokio::test] +async fn token_expires() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; + + // Call the endpoint + + let logged_in_at = fixtures::ancient(); + let identity = fixtures::identity::not_logged_in(); + let request = post::Request { name, password }; + let (identity, _) = post::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect("logged in with valid credentials"); + let secret = identity.secret().expect("logged in with valid credentials"); + + // Verify the semantics + + let expired_at = fixtures::now(); + app.tokens() + .expire(&expired_at) + .await + .expect("expiring tokens never fails"); + + let verified_at = fixtures::now(); + let error = app + .tokens() + .validate(&secret, &verified_at) + .await + .expect_err("validating an expired token"); + + assert!(matches!(error, app::ValidateError::InvalidToken)); +} diff --git a/src/login/routes/logout/mod.rs b/src/login/routes/logout/mod.rs new file mode 100644 index 0000000..36b384e --- /dev/null +++ b/src/login/routes/logout/mod.rs @@ -0,0 +1,4 @@ +pub mod post; + +#[cfg(test)] +mod test; diff --git a/src/login/routes/logout/post.rs b/src/login/routes/logout/post.rs new file mode 100644 index 0000000..6b7a62a --- /dev/null +++ b/src/login/routes/logout/post.rs @@ -0,0 +1,47 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, Unauthorized}, + token::{app, extract::IdentityToken}, +}; + +pub async fn handler( + State(app): State, + RequestedAt(now): RequestedAt, + identity: IdentityToken, + Json(_): Json, +) -> Result<(IdentityToken, StatusCode), Error> { + if let Some(secret) = identity.secret() { + let (token, _) = app.tokens().validate(&secret, &now).await?; + app.tokens().logout(&token).await?; + } + + let identity = identity.clear(); + Ok((identity, StatusCode::NO_CONTENT)) +} + +// This forces the only valid request to be `{}`, and not the infinite +// variation allowed when there's no body extractor. +#[derive(Default, serde::Deserialize)] +pub struct Request {} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::ValidateError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + #[allow(clippy::match_wildcard_for_single_variants)] + match error { + app::ValidateError::InvalidToken => Unauthorized.into_response(), + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/login/routes/logout/test.rs b/src/login/routes/logout/test.rs new file mode 100644 index 0000000..0e70e4c --- /dev/null +++ b/src/login/routes/logout/test.rs @@ -0,0 +1,80 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, +}; + +use super::post; +use crate::{test::fixtures, token::app}; + +#[tokio::test] +async fn successful() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let now = fixtures::now(); + let login = fixtures::login::create_with_password(&app, &fixtures::now()).await; + let identity = fixtures::identity::logged_in(&app, &login, &now).await; + let secret = fixtures::identity::secret(&identity); + + // Call the endpoint + + let (response_identity, response_status) = post::handler( + State(app.clone()), + fixtures::now(), + identity.clone(), + Json::default(), + ) + .await + .expect("logged out with a valid token"); + + // Verify the return value's basic structure + + assert!(response_identity.secret().is_none()); + assert_eq!(StatusCode::NO_CONTENT, response_status); + + // Verify the semantics + + let error = app + .tokens() + .validate(&secret, &now) + .await + .expect_err("secret is invalid"); + assert!(matches!(error, app::ValidateError::InvalidToken)); +} + +#[tokio::test] +async fn no_identity() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::identity::not_logged_in(); + let (identity, status) = post::handler(State(app), fixtures::now(), identity, Json::default()) + .await + .expect("logged out with no token"); + + // Verify the return value's basic structure + + assert!(identity.secret().is_none()); + assert_eq!(StatusCode::NO_CONTENT, status); +} + +#[tokio::test] +async fn invalid_token() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::identity::fictitious(); + let post::Error(error) = post::handler(State(app), fixtures::now(), identity, Json::default()) + .await + .expect_err("logged out with an invalid token"); + + // Verify the return value's basic structure + + assert!(matches!(error, app::ValidateError::InvalidToken)); +} diff --git a/src/login/routes/mod.rs b/src/login/routes/mod.rs new file mode 100644 index 0000000..8cb8852 --- /dev/null +++ b/src/login/routes/mod.rs @@ -0,0 +1,12 @@ +use axum::{routing::post, Router}; + +use crate::app::App; + +mod login; +mod logout; + +pub fn router() -> Router { + Router::new() + .route("/api/auth/login", post(login::post::handler)) + .route("/api/auth/logout", post(logout::post::handler)) +} diff --git a/src/login/routes/test/login.rs b/src/login/routes/test/login.rs deleted file mode 100644 index 68c92de..0000000 --- a/src/login/routes/test/login.rs +++ /dev/null @@ -1,128 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, -}; - -use crate::{login::routes, test::fixtures, token::app}; - -#[tokio::test] -async fn correct_credentials() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; - - // Call the endpoint - - let identity = fixtures::identity::not_logged_in(); - let logged_in_at = fixtures::now(); - let request = routes::LoginRequest { - name: name.clone(), - password, - }; - let (identity, status) = - routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect("logged in with valid credentials"); - - // Verify the return value's basic structure - - assert_eq!(StatusCode::NO_CONTENT, status); - let secret = identity.secret().expect("logged in with valid credentials"); - - // Verify the semantics - - let validated_at = fixtures::now(); - let (_, validated_login) = app - .tokens() - .validate(&secret, &validated_at) - .await - .expect("identity secret is valid"); - - assert_eq!(name, validated_login.name); -} - -#[tokio::test] -async fn invalid_name() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::identity::not_logged_in(); - let logged_in_at = fixtures::now(); - let (name, password) = fixtures::login::propose(); - let request = routes::LoginRequest { - name: name.clone(), - password, - }; - let routes::LoginError(error) = - routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect_err("logged in with an incorrect password"); - - // Verify the return value's basic structure - - assert!(matches!(error, app::LoginError::Rejected)); -} - -#[tokio::test] -async fn incorrect_password() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let login = fixtures::login::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let logged_in_at = fixtures::now(); - let identity = fixtures::identity::not_logged_in(); - let request = routes::LoginRequest { - name: login.name, - password: fixtures::login::propose_password(), - }; - let routes::LoginError(error) = - routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect_err("logged in with an incorrect password"); - - // Verify the return value's basic structure - - assert!(matches!(error, app::LoginError::Rejected)); -} - -#[tokio::test] -async fn token_expires() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; - - // Call the endpoint - - let logged_in_at = fixtures::ancient(); - let identity = fixtures::identity::not_logged_in(); - let request = routes::LoginRequest { name, password }; - let (identity, _) = routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect("logged in with valid credentials"); - let secret = identity.secret().expect("logged in with valid credentials"); - - // Verify the semantics - - let expired_at = fixtures::now(); - app.tokens() - .expire(&expired_at) - .await - .expect("expiring tokens never fails"); - - let verified_at = fixtures::now(); - let error = app - .tokens() - .validate(&secret, &verified_at) - .await - .expect_err("validating an expired token"); - - assert!(matches!(error, app::ValidateError::InvalidToken)); -} diff --git a/src/login/routes/test/logout.rs b/src/login/routes/test/logout.rs deleted file mode 100644 index 611829e..0000000 --- a/src/login/routes/test/logout.rs +++ /dev/null @@ -1,97 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, -}; - -use crate::{login::routes, test::fixtures, token::app}; - -#[tokio::test] -async fn successful() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let now = fixtures::now(); - let login = fixtures::login::create_with_password(&app, &fixtures::now()).await; - let identity = fixtures::identity::logged_in(&app, &login, &now).await; - let secret = fixtures::identity::secret(&identity); - - // Call the endpoint - - let (response_identity, response_status) = routes::on_logout( - State(app.clone()), - fixtures::now(), - identity.clone(), - Json(routes::LogoutRequest {}), - ) - .await - .expect("logged out with a valid token"); - - // Verify the return value's basic structure - - assert!(response_identity.secret().is_none()); - assert_eq!(StatusCode::NO_CONTENT, response_status); - - // Verify the semantics - - let error = app - .tokens() - .validate(&secret, &now) - .await - .expect_err("secret is invalid"); - match error { - app::ValidateError::InvalidToken => (), // should be invalid - other @ app::ValidateError::DatabaseError(_) => { - panic!("expected ValidateError::InvalidToken, got {other:#}") - } - } -} - -#[tokio::test] -async fn no_identity() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::identity::not_logged_in(); - let (identity, status) = routes::on_logout( - State(app), - fixtures::now(), - identity, - Json(routes::LogoutRequest {}), - ) - .await - .expect("logged out with no token"); - - // Verify the return value's basic structure - - assert!(identity.secret().is_none()); - assert_eq!(StatusCode::NO_CONTENT, status); -} - -#[tokio::test] -async fn invalid_token() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::identity::fictitious(); - let error = routes::on_logout( - State(app), - fixtures::now(), - identity, - Json(routes::LogoutRequest {}), - ) - .await - .expect_err("logged out with an invalid token"); - - // Verify the return value's basic structure - - assert!(matches!( - error, - routes::LogoutError::ValidateError(app::ValidateError::InvalidToken) - )); -} diff --git a/src/login/routes/test/mod.rs b/src/login/routes/test/mod.rs deleted file mode 100644 index 90522c4..0000000 --- a/src/login/routes/test/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod login; -mod logout; -- cgit v1.2.3