diff --git a/tests/basic/mod.rs b/tests/basic/mod.rs new file mode 100644 index 00000000..dcc012f3 --- /dev/null +++ b/tests/basic/mod.rs @@ -0,0 +1,85 @@ +use actix_web::{ + body::MessageBody, + http::{self, StatusCode}, + test, +}; +use sqlpage::webserver::http::main_handler; + +use crate::common::{get_request_to, req_path}; + +#[actix_web::test] +async fn test_index_ok() { + let resp = req_path("/").await.unwrap(); + assert_eq!(resp.status(), http::StatusCode::OK); + let body = test::read_body(resp).await; + assert!(body.starts_with(b"")); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!(body.contains("It works !")); + assert!(!body.contains("error")); +} + +#[actix_web::test] +async fn test_access_config_forbidden() { + let resp_result = req_path("/sqlpage/sqlpage.json").await; + assert!(resp_result.is_err(), "Accessing the config file should be forbidden, but we received a response: {resp_result:?}"); + let resp = resp_result.unwrap_err().error_response(); + assert_eq!(resp.status(), http::StatusCode::FORBIDDEN); + assert!( + String::from_utf8_lossy(&resp.into_body().try_into_bytes().unwrap()) + .to_lowercase() + .contains("forbidden"), + ); +} + +#[actix_web::test] +async fn test_404() { + for f in [ + "/does_not_exist.sql", + "/does_not_exist.html", + "/does_not_exist/", + ] { + let resp_result = req_path(f).await; + let resp = resp_result.unwrap_err().error_response(); + assert_eq!(resp.status(), http::StatusCode::NOT_FOUND, "{f} isnt 404"); + } +} + +#[actix_web::test] +async fn test_404_fallback() { + for f in [ + "/tests/does_not_exist.sql", + "/tests/does_not_exist.html", + "/tests/does_not_exist/", + ] { + let resp_result = req_path(f).await; + let resp = resp_result.unwrap(); + assert_eq!(resp.status(), http::StatusCode::OK, "{f} isnt 200"); + + let body = test::read_body(resp).await; + assert!(body.starts_with(b"")); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!(body.contains("But the ")); + assert!(body.contains("404.sql")); + assert!(body.contains("file saved the day!")); + assert!(!body.contains("error")); + } +} + +#[actix_web::test] +async fn test_static_files() { + let resp = req_path("/tests/it_works.txt").await.unwrap(); + assert_eq!(resp.status(), http::StatusCode::OK); + let body = test::read_body(resp).await; + assert_eq!(&body, &b"It works !"[..]); +} + +#[actix_web::test] +async fn test_spaces_in_file_names() { + let resp = req_path("/tests/core/spaces%20in%20file%20name.sql") + .await + .unwrap(); + assert_eq!(resp.status(), http::StatusCode::OK); + let body = test::read_body(resp).await; + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!(body_str.contains("It works !"), "{body_str}"); +} \ No newline at end of file diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 00000000..f99362a1 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,104 @@ +use std::{collections::HashMap, path::PathBuf, time::Duration}; + +use actix_web::{ + http::header::ContentType, + test::{self, TestRequest}, + web::Data, +}; +use sqlpage::{ + app_config::{test_database_url, AppConfig}, + webserver::{ + http::{form_config, main_handler, payload_config}, + }, + AppState, +}; + +pub async fn get_request_to_with_data( + path: &str, + data: Data, +) -> actix_web::Result { + Ok(test::TestRequest::get() + .uri(path) + .insert_header(ContentType::plaintext()) + .app_data(payload_config(&data)) + .app_data(form_config(&data)) + .app_data(data)) +} + +pub async fn get_request_to(path: &str) -> actix_web::Result { + let data = make_app_data().await; + get_request_to_with_data(path, data).await +} + +pub async fn make_app_data_from_config(config: AppConfig) -> Data { + let state = AppState::init(&config).await.unwrap(); + Data::new(state) +} + +pub async fn make_app_data() -> Data { + init_log(); + let config = test_config(); + make_app_data_from_config(config).await +} + +pub async fn req_path( + path: impl AsRef, +) -> Result { + let req = get_request_to(path.as_ref()).await?.to_srv_request(); + main_handler(req).await +} + +pub async fn srv_req_path_with_app_data( + path: impl AsRef, + app_data: Data, +) -> actix_web::dev::ServiceRequest { + test::TestRequest::get() + .uri(path.as_ref()) + .app_data(app_data) + .insert_header(("cookie", "test_cook=123")) + .insert_header(("authorization", "Basic dGVzdDp0ZXN0")) // test:test + .to_srv_request() +} + +const REQ_TIMEOUT: Duration = Duration::from_secs(8); +pub async fn req_path_with_app_data( + path: impl AsRef, + app_data: Data, +) -> anyhow::Result { + let path = path.as_ref(); + let req = srv_req_path_with_app_data(path, app_data).await; + let resp = tokio::time::timeout(REQ_TIMEOUT, main_handler(req)) + .await + .map_err(|e| anyhow::anyhow!("Request to {path} timed out: {e}"))? + .map_err(|e| { + anyhow::anyhow!( + "Request to {path} failed with status {}: {e:#}", + e.as_response_error().status_code() + ) + })?; + Ok(resp) +} + +pub fn test_config() -> AppConfig { + let db_url = test_database_url(); + serde_json::from_str::(&format!( + r#"{{ + "database_url": "{db_url}", + "max_database_pool_connections": 1, + "database_connection_retries": 3, + "database_connection_acquire_timeout_seconds": 15, + "allow_exec": true, + "max_uploaded_file_size": 123456, + "listen_on": "111.111.111.111:1", + "system_root_ca_certificates" : false + }}"# + )) + .unwrap() +} + +pub fn init_log() { + let _ = env_logger::builder() + .parse_default_env() + .is_test(true) + .try_init(); +} \ No newline at end of file diff --git a/tests/any_component.sql b/tests/components/any_component.sql similarity index 100% rename from tests/any_component.sql rename to tests/components/any_component.sql diff --git a/tests/display_form_field.sql b/tests/components/display_form_field.sql similarity index 100% rename from tests/display_form_field.sql rename to tests/components/display_form_field.sql diff --git a/tests/display_text.sql b/tests/components/display_text.sql similarity index 100% rename from tests/display_text.sql rename to tests/components/display_text.sql diff --git a/tests/components/mod.rs b/tests/components/mod.rs new file mode 100644 index 00000000..ceef8624 --- /dev/null +++ b/tests/components/mod.rs @@ -0,0 +1,28 @@ +use actix_web::{ + http::StatusCode, + test, +}; +use sqlpage::webserver::http::main_handler; + +use crate::common::get_request_to; + +#[actix_web::test] +async fn test_overwrite_variable() -> actix_web::Result<()> { + let req = get_request_to("/tests/sql_test_files/it_works_set_variable.sql") + .await? + .set_form(std::collections::HashMap::<&str, &str>::from_iter([( + "what_does_it_do", + "does not overwrite variables", + )])) + .to_srv_request(); + let resp = main_handler(req).await?; + + assert_eq!(resp.status(), StatusCode::OK); + let body = test::read_body(resp).await; + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!( + body_str.contains("It works !"), + "{body_str}\nexpected to contain: It works !" + ); + Ok(()) +} \ No newline at end of file diff --git a/tests/.hidden.sql b/tests/core/.hidden.sql similarity index 100% rename from tests/.hidden.sql rename to tests/core/.hidden.sql diff --git a/tests/core/mod.rs b/tests/core/mod.rs new file mode 100644 index 00000000..8d20857c --- /dev/null +++ b/tests/core/mod.rs @@ -0,0 +1,199 @@ +use actix_web::{ + http::{self, StatusCode}, + test, +}; +use sqlpage::{ + webserver, + AppState, +}; +use sqlx::Executor as _; + +use crate::common::{make_app_data_from_config, req_path, req_path_with_app_data, test_config}; + +#[actix_web::test] +async fn test_concurrent_requests() { + let components = [ + "table", "form", "card", "datagrid", "hero", "list", "timeline", + ]; + let app_data = make_app_data_from_config(test_config()).await; + let reqs = (0..64) + .map(|i| { + let component = components[i % components.len()]; + req_path_with_app_data( + format!("/tests/components/any_component.sql?component={component}"), + app_data.clone(), + ) + }) + .collect::>(); + let results = futures_util::future::join_all(reqs).await; + for result in results.into_iter() { + let resp = result.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = test::read_body(resp).await; + assert!( + body.starts_with(b""), + "Expected html doctype" + ); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!( + body.contains("It works !"), + "Expected to contain: It works !, but got: {body}" + ); + assert!(!body.contains("error")); + } +} + +#[actix_web::test] +async fn test_routing_with_db_fs() { + let mut config = test_config(); + if config.database_url.contains("memory") { + return; + } + + config.site_prefix = "/prefix/".to_string(); + let state = AppState::init(&config).await.unwrap(); + + let create_table_sql = sqlpage::filesystem::DbFsQueries::get_create_table_sql(state.db.connection.any_kind()); + state + .db + .connection + .execute(format!("DROP TABLE IF EXISTS sqlpage_files; {create_table_sql}").as_ref()) + .await + .unwrap(); + + let insert_sql = match state.db.connection.any_kind() { + sqlx::any::AnyKind::Mssql => "INSERT INTO sqlpage_files(path, contents) VALUES ('on_db.sql', CONVERT(VARBINARY(MAX), 'select ''text'' as component, ''Hi from db !'' AS contents;'))", + _ => "INSERT INTO sqlpage_files(path, contents) VALUES ('on_db.sql', 'select ''text'' as component, ''Hi from db !'' AS contents;')" + }; + state.db.connection.execute(insert_sql).await.unwrap(); + + let state = AppState::init(&config).await.unwrap(); + let app_data = actix_web::web::Data::new(state); + + let resp = req_path_with_app_data("/prefix/on_db.sql", app_data.clone()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = test::read_body(resp).await; + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!( + body_str.contains("Hi from db !"), + "{body_str}\nexpected to contain: Hi from db !" + ); +} + +#[actix_web::test] +async fn test_routing_with_prefix() { + let mut config = test_config(); + config.site_prefix = "/prefix/".to_string(); + let state = AppState::init(&config).await.unwrap(); + + let app_data = actix_web::web::Data::new(state); + let resp = req_path_with_app_data( + "/prefix/tests/sql_test_files/it_works_simple.sql", + app_data.clone(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = test::read_body(resp).await; + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!( + body_str.contains("It works !"), + "{body_str}\nexpected to contain: It works !" + ); + assert!( + body_str.contains("href=\"/prefix/"), + "{body_str}\nexpected to contain links with site prefix" + ); + + let resp = req_path_with_app_data("/prefix/nonexistent.sql", app_data.clone()) + .await + .expect_err("Expected 404 error") + .to_string(); + assert!( + resp.contains("404"), + "Response should contain \"404\", but got:\n{resp}" + ); + + let resp = req_path_with_app_data("/prefix/sqlpage/migrations/0001_init.sql", app_data.clone()) + .await + .expect_err("Expected forbidden error") + .to_string(); + assert!(resp.to_lowercase().contains("forbidden"), "{resp}"); + + let resp = req_path_with_app_data("/tests/sql_test_files/it_works_simple.sql", app_data) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::MOVED_PERMANENTLY); + let location = resp + .headers() + .get("location") + .expect("location header should be present"); + assert_eq!(location.to_str().unwrap(), "/prefix/"); +} + +#[actix_web::test] +async fn test_hidden_files() { + let resp_result = req_path("/tests/core/.hidden.sql").await; + assert!( + resp_result.is_err(), + "Accessing a hidden file should be forbidden, but received success: {resp_result:?}" + ); + let resp = resp_result.unwrap_err().error_response(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let srv_resp = actix_web::test::TestRequest::default().to_srv_response(resp); + let body = test::read_body(srv_resp).await; + assert!( + String::from_utf8_lossy(&body) + .to_lowercase() + .contains("forbidden"), + ); +} + +#[actix_web::test] +async fn test_official_website_documentation() { + let app_data = make_app_data_for_official_website().await; + let resp = req_path_with_app_data("/component.sql?component=button", app_data) + .await + .unwrap_or_else(|e| { + panic!("Failed to get response for /component.sql?component=button: {e}") + }); + assert_eq!(resp.status(), StatusCode::OK); + let body = test::read_body(resp).await; + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!( + body_str.contains(r#"