diff --git a/src/server/routes/auth/mod.rs b/src/server/routes/auth/mod.rs new file mode 100644 index 0000000..3d9f442 --- /dev/null +++ b/src/server/routes/auth/mod.rs @@ -0,0 +1,67 @@ +use axum::{http::StatusCode, response::IntoResponse, routing::post, Form, Router}; +use serde::Deserialize; +use tracing::info; + +pub fn build() -> Router { + Router::new() + .route("/login", post(login)) + .route("/logout", post(logout)) +} + +#[derive(Deserialize)] +pub struct LoginForm { + username: String, + password: String, +} + +pub async fn login(Form(form): Form) -> Result<(), LoginError> { + info!(form.username, form.password, "login attempt"); + + if form.username != "admin" { + return Err(LoginError::UnknownUser); + } + + if form.password != "hunter2" { + return Err(LoginError::InvalidPassword); + } + + Ok(()) +} + +pub enum LoginError { + UnknownUser, + InvalidPassword, +} + +impl IntoResponse for LoginError { + fn into_response(self) -> axum::response::Response { + match self { + LoginError::UnknownUser => (StatusCode::UNAUTHORIZED, "Unknown User"), + LoginError::InvalidPassword => (StatusCode::UNAUTHORIZED, "Invalid Password"), + } + .into_response() + } +} + +pub async fn logout() -> Result<(), LogoutError> { + info!("logout attempt"); + + if true { + return Err(LogoutError::NotLoggedIn); + } + + Ok(()) +} + +pub enum LogoutError { + NotLoggedIn, +} + +impl IntoResponse for LogoutError { + fn into_response(self) -> axum::response::Response { + match self { + LogoutError::NotLoggedIn => (StatusCode::UNAUTHORIZED, "Unknown User"), + } + .into_response() + } +} diff --git a/src/server/routes/mod.rs b/src/server/routes/mod.rs index 75cf547..97a764b 100644 --- a/src/server/routes/mod.rs +++ b/src/server/routes/mod.rs @@ -1,7 +1,12 @@ +mod auth; + use axum::{routing::get, Router}; pub fn build() -> Router { - Router::new().route("/health", get(health_check)) + let auth = auth::build(); + Router::new() + .route("/health", get(health_check)) + .nest("/auth", auth) } // just always returns a 200 OK for now, the server has no state, if it's up, it's working diff --git a/tests/auth.rs b/tests/auth.rs new file mode 100644 index 0000000..d8efd93 --- /dev/null +++ b/tests/auth.rs @@ -0,0 +1,72 @@ +pub mod fixture; +use fixture::TestServer; + +use anyhow::Result; +use test_log::test as traced; + +#[traced(tokio::test)] +async fn login_succeeds_with_valid_credentials() -> Result<()> { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + let resp = client + .post(server.url("/auth/login")) + .header("Content-Type", "application/x-www-form-urlencoded") + .body("username=admin&password=hunter2") + .send() + .await?; + + assert_eq!(resp.status(), 200, "health check failed"); + + // TODO: + //assert!(resp.headers().get("Set-Cookie").is_some(), "cookie set"); + + server.shutdown().await +} + +#[traced(tokio::test)] +async fn login_fails_with_invalid_credentials() -> Result<()> { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + let resp = client + .post(server.url("/auth/login")) + .header("Content-Type", "application/x-www-form-urlencoded") + .body("username=admin&password=hunter3") + .send() + .await?; + + assert_ne!( + resp.status(), + 200, + "login suceeded with invalid credentials" + ); + assert!( + resp.headers().get("Set-Cookie").is_none(), + "auth cookie was set for invalid crednetials" + ); + + server.shutdown().await +} + +#[traced(tokio::test)] +async fn login_rejects_missing_credentials() -> Result<()> { + let server = TestServer::spawn().await; + let client = reqwest::Client::new(); + let resp = client + .post(server.url("/auth/login")) + .header("Content-Type", "application/x-www-form-urlencoded") + .body("username=&password=") + .send() + .await?; + + assert_eq!( + resp.status(), + 401, + "login didn't reject missing credentials" + ); + assert!( + resp.headers().get("Set-Cookie").is_none(), + "auth cookie was set for missing crednetials" + ); + + server.shutdown().await +} diff --git a/tests/basic.rs b/tests/basic.rs index e580717..d9898cc 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -1,48 +1,10 @@ -use anyhow::{Context, Result}; -use futures_util::FutureExt; -use std::net::SocketAddr; -use test_log::test; -use tokio::{sync::oneshot, task::JoinHandle}; -use tracing::info; -use zero_to_axum::ZeroToAxum; +pub mod fixture; +use fixture::TestServer; -struct TestServer { - server_task_handle: JoinHandle<()>, - shutdown_handle: oneshot::Sender<()>, - addr: SocketAddr, -} +use anyhow::Result; +use test_log::test as traced; -impl TestServer { - async fn spawn() -> TestServer { - info!("start server"); - let mut server = ZeroToAxum::serve("[::]:0".parse().unwrap()); - let shutdown_handle = server.take_shutdown_handle().unwrap(); - let addr = server.local_addr(); - let server_task_handle = tokio::spawn(server.map(|res| res.unwrap())); - info!("server spawned"); - - TestServer { - server_task_handle, - shutdown_handle, - addr, - } - } - - /// format a URL for the given path - fn url(&self, path: &str) -> String { - format!("http://{}{path}", self.addr) - } - - /// Request a graceful shutdown and then wait for shutdown to complete - async fn shutdown(self) -> Result<()> { - self.shutdown_handle.send(()).ok(); - self.server_task_handle - .await - .context("wait for server shutdown") - } -} - -#[test(tokio::test)] +#[traced(tokio::test)] async fn health_check() -> Result<()> { let server = TestServer::spawn().await; let status = reqwest::get(server.url("/health")).await?.status(); diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs new file mode 100644 index 0000000..456ba0e --- /dev/null +++ b/tests/fixture/mod.rs @@ -0,0 +1,42 @@ +use anyhow::{Context, Result}; +use futures_util::FutureExt; +use std::net::SocketAddr; +use tokio::{sync::oneshot, task::JoinHandle}; +use tracing::info; +use zero_to_axum::ZeroToAxum; + +pub struct TestServer { + server_task_handle: JoinHandle<()>, + shutdown_handle: oneshot::Sender<()>, + addr: SocketAddr, +} + +impl TestServer { + pub async fn spawn() -> TestServer { + info!("start server"); + let mut server = ZeroToAxum::serve("[::]:0".parse().unwrap()); + let shutdown_handle = server.take_shutdown_handle().unwrap(); + let addr = server.local_addr(); + let server_task_handle = tokio::spawn(server.map(|res| res.unwrap())); + info!("server spawned"); + + TestServer { + server_task_handle, + shutdown_handle, + addr, + } + } + + /// format a URL for the given path + pub fn url(&self, path: &str) -> String { + format!("http://{}{path}", self.addr) + } + + /// Request a graceful shutdown and then wait for shutdown to complete + pub async fn shutdown(self) -> Result<()> { + self.shutdown_handle.send(()).ok(); + self.server_task_handle + .await + .context("wait for server shutdown") + } +}