diff options
Diffstat (limited to 'src/login')
| -rw-r--r-- | src/login/app.rs | 19 | ||||
| -rw-r--r-- | src/login/extract.rs | 9 | ||||
| -rw-r--r-- | src/login/routes.rs | 5 | ||||
| -rw-r--r-- | src/login/routes/test/boot.rs | 9 | ||||
| -rw-r--r-- | src/login/routes/test/login.rs | 137 | ||||
| -rw-r--r-- | src/login/routes/test/logout.rs | 86 | ||||
| -rw-r--r-- | src/login/routes/test/mod.rs | 3 |
7 files changed, 268 insertions, 0 deletions
diff --git a/src/login/app.rs b/src/login/app.rs index 292a564..10609c6 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -48,6 +48,17 @@ impl<'a> Logins<'a> { Ok(token) } + #[cfg(test)] + pub async fn create(&self, name: &str, password: &str) -> Result<Login, CreateError> { + let password_hash = StoredHash::new(password)?; + + let mut tx = self.db.begin().await?; + let login = tx.logins().create(name, &password_hash).await?; + tx.commit().await?; + + Ok(login) + } + pub async fn validate(&self, secret: &str, used_at: &DateTime) -> Result<Login, ValidateError> { // Somewhat arbitrarily, expire after 7 days. let expire_at = used_at.to_owned() - TimeDelta::days(7); @@ -87,6 +98,14 @@ pub enum LoginError { PasswordHashError(#[from] password_hash::Error), } +#[cfg(test)] +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum CreateError { + DatabaseError(#[from] sqlx::Error), + PasswordHashError(#[from] password_hash::Error), +} + #[derive(Debug, thiserror::Error)] pub enum ValidateError { #[error("invalid token")] diff --git a/src/login/extract.rs b/src/login/extract.rs index 735bc22..bda55cd 100644 --- a/src/login/extract.rs +++ b/src/login/extract.rs @@ -7,11 +7,20 @@ use axum_extra::extract::cookie::{Cookie, CookieJar}; // The usage pattern here - receive the extractor as an argument, return it in // the response - is heavily modelled after CookieJar's own intended usage. +#[derive(Clone, Debug)] pub struct IdentityToken { cookies: CookieJar, } impl IdentityToken { + /// Creates a new, unpopulated identity token store. + #[cfg(test)] + pub fn new() -> Self { + Self { + cookies: CookieJar::new(), + } + } + /// Get the identity secret sent in the request, if any. If the identity /// was not sent, or if it has previously been [clear]ed, then this will /// return [None]. If the identity has previously been [set], then this diff --git a/src/login/routes.rs b/src/login/routes.rs index 41554dd..06e5853 100644 --- a/src/login/routes.rs +++ b/src/login/routes.rs @@ -10,6 +10,9 @@ use crate::{app::App, clock::RequestedAt, error::InternalError, repo::login::Log use super::{app, extract::IdentityToken}; +#[cfg(test)] +mod test; + pub fn router() -> Router<App> { Router::new() .route("/api/boot", get(boot)) @@ -53,6 +56,7 @@ async fn on_login( Ok((identity, StatusCode::NO_CONTENT)) } +#[derive(Debug)] struct LoginError(app::LoginError); impl IntoResponse for LoginError { @@ -85,6 +89,7 @@ async fn on_logout( Ok((identity, StatusCode::NO_CONTENT)) } +#[derive(Debug)] struct LogoutError(app::ValidateError); impl IntoResponse for LogoutError { diff --git a/src/login/routes/test/boot.rs b/src/login/routes/test/boot.rs new file mode 100644 index 0000000..dee554f --- /dev/null +++ b/src/login/routes/test/boot.rs @@ -0,0 +1,9 @@ +use crate::{login::routes, test::fixtures}; + +#[tokio::test] +async fn returns_identity() { + let login = fixtures::login::fictitious(); + let response = routes::boot(login.clone()).await; + + assert_eq!(login, response.login); +} diff --git a/src/login/routes/test/login.rs b/src/login/routes/test/login.rs new file mode 100644 index 0000000..4fa491a --- /dev/null +++ b/src/login/routes/test/login.rs @@ -0,0 +1,137 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, +}; + +use crate::{ + login::{app, routes}, + test::fixtures, +}; + +#[tokio::test] +async fn new_identity() { + // 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 (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 = app + .logins() + .validate(secret, &validated_at) + .await + .expect("identity secret is valid"); + + assert_eq!(name, validated.name); +} + +#[tokio::test] +async fn existing_identity() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let (name, password) = fixtures::login::create_for_login(&app).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 + .logins() + .validate(secret, &validated_at) + .await + .expect("identity secret is valid"); + + assert_eq!(name, validated_login.name); +} + +#[tokio::test] +async fn authentication_failed() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let login = fixtures::login::create(&app).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 + + fixtures::error::expected!(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_for_login(&app).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 token = identity.secret().expect("logged in with valid credentials"); + + // Verify the semantics + + let verified_at = fixtures::now(); + let error = app + .logins() + .validate(token, &verified_at) + .await + .expect_err("validating an expired token"); + + fixtures::error::expected!(error, app::ValidateError::InvalidToken); +} diff --git a/src/login/routes/test/logout.rs b/src/login/routes/test/logout.rs new file mode 100644 index 0000000..003bc8e --- /dev/null +++ b/src/login/routes/test/logout.rs @@ -0,0 +1,86 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, +}; + +use crate::{ + login::{app, routes}, + test::fixtures, +}; + +#[tokio::test] +async fn successful() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let now = fixtures::now(); + let login = fixtures::login::create_for_login(&app).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()), + 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 + .logins() + .validate(secret, &now) + .await + .expect_err("secret is invalid"); + match error { + app::ValidateError::InvalidToken => (), // should be invalid + other => 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), 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 routes::LogoutError(error) = + routes::on_logout(State(app), identity, Json(routes::LogoutRequest {})) + .await + .expect_err("logged out with an invalid token"); + + // Verify the return value's basic structure + + fixtures::error::expected!(error, app::ValidateError::InvalidToken); +} diff --git a/src/login/routes/test/mod.rs b/src/login/routes/test/mod.rs new file mode 100644 index 0000000..7693755 --- /dev/null +++ b/src/login/routes/test/mod.rs @@ -0,0 +1,3 @@ +mod boot; +mod login; +mod logout; |
