diff --git a/Cargo.lock b/Cargo.lock index 8b78390..86a92e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,35 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "axum-test" +version = "18.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3290e73c56c5cc4701cdd7d46b9ced1b4bd61c7e9f9c769a9e9e87ff617d75d2" +dependencies = [ + "anyhow", + "axum", + "bytes", + "bytesize", + "cookie", + "expect-json", + "http", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -509,6 +538,12 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + [[package]] name = "cc" version = "1.2.45" @@ -994,6 +1029,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1123,6 +1164,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1191,6 +1241,35 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "expect-json" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422e7906e79941e5ac58c64dfd2da03e6ae3de62227f87606fbbe125d91080f9" +dependencies = [ + "chrono", + "email_address", + "expect-json-macros", + "num", + "regex", + "serde", + "serde_json", + "thiserror", + "typetag", + "uuid", +] + +[[package]] +name = "expect-json-macros" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b515b7f10f1e61bfd938522e9884509b82060af2016153f5b3d6f44d6da89c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1925,6 +2004,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2752,6 +2840,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -3126,6 +3224,15 @@ dependencies = [ "webpki-roots 1.0.4", ] +[[package]] +name = "reserve-port" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" +dependencies = [ + "thiserror", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -3223,6 +3330,21 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "rand 0.9.2", + "thiserror", +] + [[package]] name = "rust_decimal" version = "1.39.0" @@ -4695,6 +4817,30 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "typetag" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "ucd-trie" version = "0.1.7" @@ -5444,6 +5590,7 @@ dependencies = [ "async-trait", "axum", "axum-extra", + "axum-test", "chrono", "clap", "config", diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 0a3b4df..b71f3a1 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -35,6 +35,7 @@ optfield = { version = "0.4.0" } [dev-dependencies] tempfile = "3" +axum-test = "18.4.1" [lints.clippy] unwrap_used = "deny" diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs index 6a475f7..44cb0e4 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs @@ -153,3 +153,202 @@ pub async fn get_upstream( // Ok(Json(upstream_info.into())) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + + use database::generated::entities::{upstream, upstream_target}; + + use crate::configs::{FromConfig, ProgramSettings}; + + use crate::routes::api::restricted::nginx::upstream::get_upstream_router; + use crate::services::get_app_service; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_upstream_router(state) + } + + #[tokio::test] + async fn handler_get_upstream_list_returns_list() { + let u1 = upstream::Model { + id: uuid::Uuid::new_v4(), + name: "u1".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let u2 = upstream::Model { + id: uuid::Uuid::new_v4(), + name: "u2".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![u1.clone(), u2.clone()]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server.get("/upstreams").await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.items.len(), 2); + assert_eq!(body.pagination.current_page, 1u32); + } + + #[tokio::test] + async fn handler_get_upstream_with_targets_returns_targets() { + let up_id = uuid::Uuid::new_v4(); + + let up_model = upstream::Model { + id: up_id, + name: "with_targets".to_string(), + protocol: "http".to_string(), + algorithm: "least_conn".to_string(), + sticky_session: true, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let target_model = upstream_target::Model { + id: uuid::Uuid::new_v4(), + upstream_id: up_id, + target_host: "127.0.0.1".to_string(), + target_port: 8080, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + // find_by_id -> returns upstream model + .append_query_results(vec![vec![up_model.clone()]]) + // find targets -> returns the target(s) + .append_query_results(vec![vec![target_model.clone()]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let url = format!("/upstreams/{}?include_targets=true", up_id); + let res = server.get(&url).await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.id, up_id); + assert_eq!(body.upstream_targets.len(), 1); + assert_eq!(body.upstream_targets[0].target_host, "127.0.0.1"); + } + + #[tokio::test] + async fn extractor_pagination_validation_rejects_bad_values() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + // page = 0 should be rejected + let res = server.get("/upstreams?page=0&per_page=10").await; + res.assert_status(StatusCode::BAD_REQUEST); + + // per_page out of range should be rejected + let res = server.get("/upstreams?page=1&per_page=0").await; + res.assert_status(StatusCode::BAD_REQUEST); + + // valid values accepted + let res = server.get("/upstreams?page=2&per_page=5").await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.pagination.current_page, 2u32); + assert_eq!(body.pagination.per_page, 5u32); + } + + #[tokio::test] + async fn handler_get_upstream_not_found_returns_service_error() { + let up_id = uuid::Uuid::new_v4(); + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let url = format!("/upstreams/{}?include_targets=false", up_id); + let res = server.get(&url).await; + res.assert_status(StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn handler_get_upstream_without_targets_returns_info() { + let up_id = uuid::Uuid::new_v4(); + + let up_model = upstream::Model { + id: up_id, + name: "simple_up".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + // find_by_id -> returns upstream model + .append_query_results(vec![vec![up_model.clone()]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + // include_targets omitted -> should not include targets + let url = format!("/upstreams/{}", up_id); + let res = server.get(&url).await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.id, up_id); + assert!(body.upstream_targets.is_empty()); + } + + #[tokio::test] + async fn handler_get_upstream_list_empty_returns_empty_items() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server.get("/upstreams?page=3&per_page=10").await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.items.len(), 0); + assert_eq!(body.pagination.current_page, 3u32); + assert_eq!(body.pagination.per_page, 10u32); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs index bb2ac5a..f1206dd 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs @@ -97,3 +97,127 @@ pub async fn get_upstream_target( .await?; Ok(Json(upstream_target_info.into())) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + + use database::generated::entities::{upstream, upstream_target}; + + use crate::configs::{FromConfig, ProgramSettings}; + + use crate::routes::api::restricted::nginx::upstream::get_upstream_router; + use crate::services::get_app_service; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_upstream_router(state) + } + + #[tokio::test] + async fn handler_get_upstream_target_with_upstream_returns_upstream() { + let up_id = uuid::Uuid::new_v4(); + + let up_model = upstream::Model { + id: up_id, + name: "with_targets".to_string(), + protocol: "http".to_string(), + algorithm: "least_conn".to_string(), + sticky_session: true, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let target_id = uuid::Uuid::new_v4(); + let target_model = upstream_target::Model { + id: target_id, + upstream_id: up_id, + target_host: "127.0.0.1".to_string(), + target_port: 8080, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + // query returns joined (upstream_target, upstream) + .append_query_results(vec![vec![(target_model.clone(), Some(up_model.clone()))]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let url = format!("/upstream_targets/{}?include_upstream=true", target_id); + let res = server.get(&url).await; + res.assert_status_ok(); + let text = res.text(); + println!("response body: {}", text); + let body: UpstreamTargetInfo = serde_json::from_str(&text).expect("failed to parse json"); + assert_eq!(body.upstream_id, up_id); + assert!(body.upstream.is_some()); + let upstream = body.upstream.expect("upstream to be present"); + assert_eq!(upstream.id, up_id); + assert_eq!(upstream.name, "with_targets"); + } + + #[tokio::test] + async fn handler_get_upstream_target_without_upstream_returns_info() { + let target_id = uuid::Uuid::new_v4(); + + let target_model = upstream_target::Model { + id: target_id, + upstream_id: uuid::Uuid::new_v4(), + target_host: "10.0.0.1".to_string(), + target_port: 9090, + weight: 5, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![target_model.clone()]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let url = format!("/upstream_targets/{}", target_id); + let res = server.get(&url).await; + res.assert_status_ok(); + let text = res.text(); + let body: UpstreamTargetInfo = serde_json::from_str(&text).expect("failed to parse json"); + assert_eq!(body.id, target_id); + assert!(body.upstream.is_none()); + } + + #[tokio::test] + async fn handler_get_upstream_target_not_found_returns_service_error() { + let target_id = uuid::Uuid::new_v4(); + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let url = format!("/upstream_targets/{}?include_upstream=false", target_id); + let res = server.get(&url).await; + res.assert_status(StatusCode::NOT_FOUND); + } +}