summaryrefslogtreecommitdiff
path: root/src/login
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2024-09-19 01:25:31 -0400
committerOwen Jacobson <owen@grimoire.ca>2024-09-20 23:55:22 -0400
commite5f72711c5a17c5db24e209b14f82d426eceb86e (patch)
tree04865172284c86549dd08d700c21a29c36f54005 /src/login
parent0079624488af334817f58e30dbc676d3adde8de6 (diff)
Write tests.
Diffstat (limited to 'src/login')
-rw-r--r--src/login/app.rs19
-rw-r--r--src/login/extract.rs9
-rw-r--r--src/login/routes.rs5
-rw-r--r--src/login/routes/test/boot.rs9
-rw-r--r--src/login/routes/test/login.rs137
-rw-r--r--src/login/routes/test/logout.rs86
-rw-r--r--src/login/routes/test/mod.rs3
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;