diff --git a/Cargo.lock b/Cargo.lock index b2f2a2c..5edfe70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,10 +457,13 @@ dependencies = [ "axum", "chrono", "dotenvy", + "http-body-util", "serde", "teloxide", + "tempfile", "tokio", "toml", + "tower", "tracing", "tracing-subscriber", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 801f2ea..50b5f96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,8 @@ uuid = { version = "1", features = ["v4"] } chrono = "0.4" tracing = "0.1" tracing-subscriber = "0.3" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +tempfile = "3" diff --git a/src/web.rs b/src/web.rs index e6b2de7..4e7b21c 100644 --- a/src/web.rs +++ b/src/web.rs @@ -116,3 +116,170 @@ async fn submit( async fn style() -> impl IntoResponse { ([(header::CONTENT_TYPE, "text/css")], STYLE_CSS) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use tower::ServiceExt; + + fn test_config(dir: &std::path::Path) -> Config { + Config { + port: 0, + data_dir: dir.to_path_buf(), + site_title: "test".into(), + site_url: "https://test.rs".into(), + telegram_bot_token: "fake".into(), + telegram_chat_id: 0, + honeypot: true, + max_name_length: 50, + max_message_length: 1000, + max_website_length: 100, + open_registration: true, + } + } + + fn test_app(config: Config) -> (Router, tokio::sync::mpsc::Receiver) { + let (tx, rx) = tokio::sync::mpsc::channel(32); + let state = Arc::new(AppState { config, tx }); + (router(state), rx) + } + + async fn post_form(app: &Router, body: &str) -> (StatusCode, String) { + let req = Request::builder() + .method("POST") + .uri("/submit") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body.to_string())) + .unwrap(); + let resp = app.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + (status, String::from_utf8(bytes.to_vec()).unwrap()) + } + + async fn get_index(app: &Router) -> String { + let req = Request::builder() + .uri("/") + .body(Body::empty()) + .unwrap(); + let resp = app.clone().oneshot(req).await.unwrap(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + String::from_utf8(bytes.to_vec()).unwrap() + } + + #[tokio::test] + async fn test_open_registration_shows_form() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + let (app, _rx) = test_app(config); + let html = get_index(&app).await; + assert!(html.contains("action=\"/submit\"")); + } + + #[tokio::test] + async fn test_closed_registration_hides_form() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.open_registration = false; + let (app, _rx) = test_app(config); + let html = get_index(&app).await; + assert!(!html.contains("action=\"/submit\"")); + } + + #[tokio::test] + async fn test_closed_registration_rejects_submit() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.open_registration = false; + let (app, _rx) = test_app(config); + let (status, body) = post_form(&app, "name=test&message=hello").await; + assert_eq!(status, StatusCode::OK); + assert!(body.contains("Submissions are closed")); + } + + #[tokio::test] + async fn test_honeypot_discards() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + let (app, _rx) = test_app(config); + let (_, body) = post_form(&app, "name=bot&message=spam&url=http://spam.com").await; + assert!(body.contains("Thanks!")); + // No entry file should exist + let entries: Vec<_> = std::fs::read_dir(dir.path().join("entries")) + .into_iter() + .flatten() + .collect(); + assert!(entries.is_empty()); + } + + #[tokio::test] + async fn test_honeypot_disabled_allows_url_field() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.honeypot = false; + let (app, _rx) = test_app(config); + let (_, body) = post_form(&app, "name=user&message=hello&url=http://mysite.com").await; + assert!(body.contains("pending approval")); + let count = std::fs::read_dir(dir.path().join("entries")) + .unwrap() + .count(); + assert_eq!(count, 1); + } + + #[tokio::test] + async fn test_max_name_length() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.max_name_length = 5; + let (app, _rx) = test_app(config); + let (_, body) = post_form(&app, "name=toolong&message=hi").await; + assert!(body.contains("too long")); + } + + #[tokio::test] + async fn test_max_name_length_zero_unlimited() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.max_name_length = 0; + let (app, _rx) = test_app(config); + let long_name = "a".repeat(200); + let (_, body) = post_form(&app, &format!("name={long_name}&message=hi")).await; + assert!(body.contains("pending approval")); + } + + #[tokio::test] + async fn test_max_message_length() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.max_message_length = 10; + let (app, _rx) = test_app(config); + let (_, body) = post_form(&app, "name=test&message=this+message+is+way+too+long").await; + assert!(body.contains("too long")); + } + + #[tokio::test] + async fn test_max_website_length() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.max_website_length = 5; + let (app, _rx) = test_app(config); + let (_, body) = post_form(&app, "name=test&message=hi&website=http://toolong.com").await; + assert!(body.contains("too long")); + } + + #[tokio::test] + async fn test_valid_submission_creates_entry() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + let (app, _rx) = test_app(config); + let (_, body) = post_form(&app, "name=alice&message=hello").await; + assert!(body.contains("pending approval")); + let count = std::fs::read_dir(dir.path().join("entries")) + .unwrap() + .count(); + assert_eq!(count, 1); + } +}