diff --git a/Cargo.toml b/Cargo.toml index 7e60bcfc..7f6f089f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,5 +34,3 @@ publish = false # disable publishing to crates.io # ensure deps are compatible: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses async-std = { version = "1.13.0", features = ["attributes", "tokio1"] } rstest = "0.25.0" -serial_test = "3.2.0" -tokio = "1.43.0" diff --git a/codecov.yml b/codecov.yml index e9c9c87d..5d2d7039 100644 --- a/codecov.yml +++ b/codecov.yml @@ -15,5 +15,5 @@ comment: require_changes: false # if true: only post the comment if coverage changes ignore: - - "tests" + - "**/tests/**" - "third-party" diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index dd9c18fd..a8f256ae 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -79,8 +79,6 @@ objc2-core-foundation = "0.3.0" [dev-dependencies] async-std.workspace = true rstest.workspace = true -serial_test.workspace = true -tokio.workspace = true [package.metadata.ci] cargo-run-bin = "1.7.4" diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index cd661b7c..dd909b0d 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -76,8 +76,10 @@ impl OpenApiFromRequest<'_> for AdminGuard { /// Claims for the JWT. #[derive(Debug, Serialize, Deserialize)] pub struct Claims { - pub(crate) sub: String, - exp: usize, + /// Subject (user ID) of the JWT + pub sub: String, + /// Expiration time as Unix timestamp + pub exp: usize, } const BEARER: &str = "Bearer "; @@ -86,9 +88,9 @@ const BEARER: &str = "Bearer "; pub fn create_token( user_id: &str, secret: &str, -) -> String { +) -> Result { let expiration = chrono::Utc::now() - .checked_add_signed(chrono::Duration::seconds(60)) + .checked_add_signed(chrono::Duration::hours(24)) .expect("valid timestamp") .timestamp(); @@ -102,7 +104,6 @@ pub fn create_token( &claims, &EncodingKey::from_secret(secret.as_ref()), ) - .unwrap() } /// Decode a JWT token. @@ -161,11 +162,13 @@ pub(crate) fn get_jwt_secret() -> &'static str { &JWT_SECRET } -pub(crate) fn hash_password(password: &str) -> String { - hash(password, DEFAULT_COST).unwrap() +/// Hash a password using BCrypt (handles salting internally) +pub fn hash_password(password: &str) -> Result { + hash(password, DEFAULT_COST) } -pub(crate) fn verify_password( +/// Verify a password against a BCrypt hash +pub fn verify_password( password: &str, hash: &str, ) -> bool { diff --git a/crates/server/src/db/schema.rs b/crates/server/src/db/schema.rs index f63dc8dc..a9831b63 100644 --- a/crates/server/src/db/schema.rs +++ b/crates/server/src/db/schema.rs @@ -8,9 +8,7 @@ table! { id -> Integer, username -> Text, password -> Text, - password_salt -> Text, pin -> Nullable, - pin_salt -> Nullable, admin -> Bool, } } diff --git a/crates/server/src/web/mod.rs b/crates/server/src/web/mod.rs index 8d7705d3..b4bcd1da 100644 --- a/crates/server/src/web/mod.rs +++ b/crates/server/src/web/mod.rs @@ -18,6 +18,11 @@ use crate::globals; /// Build the web server. pub fn rocket() -> rocket::Rocket { + rocket_with_db_path(None) +} + +/// Build the web server with a custom database path (primarily for testing). +pub fn rocket_with_db_path(custom_db_path: Option) -> rocket::Rocket { // the cert path changes depending on if the user wants to use custom certs let (cert_path, key_path); if !GLOBAL_SETTINGS.server.use_custom_certs { @@ -32,12 +37,15 @@ pub fn rocket() -> rocket::Rocket { certs::ensure_certificates_exist(cert_path.clone(), key_path.clone()); } + // Use custom database path for tests, or default for production + let db_path = custom_db_path.unwrap_or_else(|| globals::APP_PATHS.db_path.clone()); + let figment = Figment::from(Config::default()) .merge(( "databases", rocket::figment::map! { "sqlite_db" => rocket::figment::map! { - "url" => format!("sqlite://{}", globals::APP_PATHS.db_path), + "url" => format!("sqlite://{}", db_path), } }, )) diff --git a/crates/server/src/web/routes/auth.rs b/crates/server/src/web/routes/auth.rs index 84efec73..b6fbd9ab 100644 --- a/crates/server/src/web/routes/auth.rs +++ b/crates/server/src/web/routes/auth.rs @@ -55,12 +55,21 @@ pub async fn login( // debug print user info from db println!("Found user in db: {:?}", user); + // Verify password using BCrypt if !crate::auth::verify_password(&form.password, &user.password) { println!("Password verification failed"); return Err(Status::Unauthorized); } - let token = crate::auth::create_token(&user.id.to_string(), crate::auth::get_jwt_secret()); + let token = match crate::auth::create_token(&user.id.to_string(), crate::auth::get_jwt_secret()) + { + Ok(token) => token, + Err(e) => { + println!("Failed to create token: {}", e); + return Err(Status::InternalServerError); + } + }; + Ok(Json(TokenResponse { token })) } diff --git a/crates/server/src/web/routes/user.rs b/crates/server/src/web/routes/user.rs index b8210c09..fd4d75ac 100644 --- a/crates/server/src/web/routes/user.rs +++ b/crates/server/src/web/routes/user.rs @@ -24,22 +24,30 @@ pub struct CreateUserForm { pub async fn create_user( db: DbConn, user_form: Json, - admin_guard: Option, + auth_guard: Option, ) -> Result<&'static str, Status> { use crate::db::schema::users::dsl::*; - let existing = db + + // Check if this is the first user (no authentication required) + let existing_count = db .run(|conn| users.count().get_result::(conn)) .await .unwrap_or(0); - // If there are users, require admin privileges - if existing > 0 && admin_guard.is_none() { + // If there are existing users, require admin privileges + if existing_count > 0 && auth_guard.is_none() { return Err(Status::Unauthorized); } let form = user_form.into_inner(); - let hashed_password = crate::auth::hash_password(&form.password); + // Hash password using BCrypt + let hashed_password = match crate::auth::hash_password(&form.password) { + Ok(hash) => hash, + Err(_) => return Err(Status::InternalServerError), + }; + + // Hash PIN if provided let hashed_pin = if let Some(pin_value) = form.pin { if pin_value.parse::().is_err() { return Err(Status::BadRequest); @@ -47,13 +55,16 @@ pub async fn create_user( if pin_value.len() < 4 || pin_value.len() > 6 { return Err(Status::BadRequest); } - Some(crate::auth::hash_password(&pin_value)) + match crate::auth::hash_password(&pin_value) { + Ok(hash) => Some(hash), + Err(_) => return Err(Status::InternalServerError), + } } else { None }; let user = User { - id: 0, + id: 0, // This will be auto-incremented by SQLite username: form.username, password: hashed_password, pin: hashed_pin, diff --git a/crates/server/tests/fixtures/mod.rs b/crates/server/tests/fixtures/mod.rs index 3ad69fed..44842db1 100644 --- a/crates/server/tests/fixtures/mod.rs +++ b/crates/server/tests/fixtures/mod.rs @@ -1,12 +1,11 @@ // standard imports use std::fs; -use std::path::Path; +use std::path::PathBuf; // lib imports use diesel::Connection; use diesel::sqlite::SqliteConnection; use diesel_migrations::MigrationHarness; -use once_cell::sync::Lazy; use rocket::http::Status; use rocket::local::asynchronous::Client; use rstest::fixture; @@ -18,27 +17,25 @@ use koko::globals::CURRENT_ENV; use koko::web::rocket; // test imports -use crate::test_web::test_request; - -// constants -static DB_PATH: Lazy<&'static Path> = Lazy::new(|| Path::new("./test_data/koko.db")); +use crate::test_utils::{TestResponse, make_request}; pub struct TestDb { pub client: Client, + db_path: PathBuf, } impl Drop for TestDb { fn drop(&mut self) { - if DB_PATH.exists() { - if let Ok(mut conn) = SqliteConnection::establish(DB_PATH.to_str().unwrap()) { + if self.db_path.exists() { + if let Ok(mut conn) = SqliteConnection::establish(self.db_path.to_str().unwrap()) { let _ = conn.revert_all_migrations(MIGRATIONS); } - // Sleep to try to all the processes to release the database file + // Sleep to allow processes to release the database file std::thread::sleep(std::time::Duration::from_secs(1)); // Delete the database file - match fs::remove_file(DB_PATH.clone()) { + match fs::remove_file(&self.db_path) { Ok(_) => (), Err(e) => eprintln!("Warning: Failed to delete test database: {}", e), } @@ -50,23 +47,36 @@ impl Drop for TestDb { pub async fn db_fixture(#[default(false)] base_user: bool) -> TestDb { CURRENT_ENV.store(1, std::sync::atomic::Ordering::SeqCst); - if let Some(parent) = DB_PATH.parent() { - fs::create_dir_all(parent).expect("Failed to create test_data directory"); - } + // Create a unique database file for this test + let test_id = std::thread::current().id(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let db_path = PathBuf::from(format!( + "./test_data/test_{}_{}.db", + timestamp, + format!("{:?}", test_id) + .replace("ThreadId(", "") + .replace(")", "") + )); - // Initialize database with migrations - if let Ok(mut conn) = SqliteConnection::establish(DB_PATH.to_str().unwrap()) { - conn.run_pending_migrations(MIGRATIONS) - .expect("Failed to run migrations"); + // Ensure test_data directory exists + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).expect("Failed to create test_data directory"); } - let rocket = rocket(); - let client = Client::tracked(rocket) + // Set the database URL for this test + std::env::set_var("DATABASE_URL", format!("sqlite:{}", db_path.display())); + + let rocket_instance = rocket(); + let client = Client::tracked(rocket_instance) .await - .expect("Failed to launch web server"); + .expect("Failed to launch rocket for test"); if base_user { - let response = test_request( + let response: TestResponse = make_request( + Some(&client), "post", "/create_user", Some(json!({ @@ -75,13 +85,14 @@ pub async fn db_fixture(#[default(false)] base_user: bool) -> TestDb { "pin": "1234", "admin": true, })), - Status::Ok, - Some(&client), + None, + Some(Status::Ok), + Some(false), ) .await; assert_eq!(response.body, "User created"); } - TestDb { client } + TestDb { client, db_path } } diff --git a/crates/server/tests/main.rs b/crates/server/tests/main.rs index 645e640b..a203a9ea 100644 --- a/crates/server/tests/main.rs +++ b/crates/server/tests/main.rs @@ -1,5 +1,7 @@ +pub mod test_auth; pub mod test_dependencies; pub mod test_tray; +pub mod test_utils; pub mod test_web; pub mod fixtures; diff --git a/crates/server/tests/test_auth.rs b/crates/server/tests/test_auth.rs new file mode 100644 index 00000000..320a045b --- /dev/null +++ b/crates/server/tests/test_auth.rs @@ -0,0 +1,145 @@ +//! Authentication tests for the application. + +// lib imports +use chrono::{Duration, Utc}; +use rstest::rstest; + +// local imports +use koko::auth::{create_token, decode_token, hash_password, verify_password}; + +#[rstest] +#[case("123", "user with numeric ID")] +#[case("admin", "user with text ID")] +#[case("user@domain.com", "user with email-like ID")] +#[case("user_with_underscores", "user with underscores")] +fn test_jwt_token_creation_and_verification( + #[case] user_id: &str, + #[case] _description: &str, +) { + let secret = "test_secret_key_for_jwt"; + + // Test token creation + let token = create_token(user_id, secret).expect("Should create token"); + assert!(!token.is_empty()); + + // Test token decoding + let claims = decode_token(&token, secret).expect("Should decode token"); + assert_eq!(claims.sub, user_id); + + // Verify expiration is in the future (24 hours) + let now = Utc::now().timestamp() as usize; + assert!(claims.exp > now); + + // Should be approximately 24 hours from now (allowing 1 minute tolerance) + let expected_exp = (Utc::now() + Duration::hours(24)).timestamp() as usize; + assert!((claims.exp as i64 - expected_exp as i64).abs() < 60); +} + +#[rstest] +#[case("test_secret_key", "wrong_secret_key")] +#[case("short", "different")] +#[case( + "very_long_secret_key_123456789", + "another_very_long_secret_key_987654321" +)] +fn test_jwt_token_with_invalid_secret( + #[case] correct_secret: &str, + #[case] wrong_secret: &str, +) { + let user_id = "test_user"; + + let token = create_token(user_id, correct_secret).expect("Should create token"); + + // Should fail with wrong secret + let result = decode_token(&token, wrong_secret); + assert!(result.is_err()); +} + +#[rstest] +#[case( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjF9.invalid", + "obviously expired token" +)] +#[case("invalid.token.format", "malformed token")] +#[case("", "empty token")] +fn test_jwt_token_invalid_cases( + #[case] invalid_token: &str, + #[case] _description: &str, +) { + let secret = "test_secret"; + + let result = decode_token(invalid_token, secret); + assert!(result.is_err()); +} + +#[test] +fn test_password_hashing_with_bcrypt() { + let password = "test_password_123"; + + // Test hashing + let hash = hash_password(password).expect("Should hash password"); + assert!(!hash.is_empty()); + assert_ne!(hash, password); // Hash should be different from password + + // Test verification with correct password + assert!(verify_password(password, &hash)); + + // Test verification with wrong password + assert!(!verify_password("wrong_password", &hash)); +} + +#[test] +fn test_same_password_different_hashes() { + let password = "same_password"; + + let hash1 = hash_password(password).expect("Should hash password"); + let hash2 = hash_password(password).expect("Should hash password"); + + // BCrypt should produce different hashes even for the same password due to internal salting + assert_ne!(hash1, hash2); + + // But both should verify correctly + assert!(verify_password(password, &hash1)); + assert!(verify_password(password, &hash2)); +} + +#[rstest] +#[case("", "empty password")] +#[case(&"a".repeat(1000), "very long password")] +#[case("!@#$%^&*()_+-=[]{}|;':\",./<>?`~", "password with special characters")] +#[case("simple123", "simple alphanumeric password")] +#[case("Κωδικός123", "password with unicode characters")] +fn test_password_hashing_edge_cases( + #[case] password: &str, + #[case] _description: &str, +) { + // Test hashing + let hash = hash_password(password).expect("Should hash password"); + assert!(!hash.is_empty()); + assert_ne!(hash, password); // Hash should be different from password + + // Test verification with correct password + assert!(verify_password(password, &hash)); + + // Test verification with wrong password (unless password is empty) + if !password.is_empty() { + assert!(!verify_password("different_password", &hash)); + } +} + +#[rstest] +#[case("password123")] +#[case("another_password")] +#[case("complex!Pass@123")] +fn test_bcrypt_salt_uniqueness(#[case] password: &str) { + // Hash the same password multiple times + let hash1 = hash_password(password).expect("Should hash password"); + let hash2 = hash_password(password).expect("Should hash password"); + + // BCrypt should produce different hashes even with same input due to internal salt + assert_ne!(hash1, hash2); + + // But both should verify correctly + assert!(verify_password(password, &hash1)); + assert!(verify_password(password, &hash2)); +} diff --git a/crates/server/tests/test_dependencies/mod.rs b/crates/server/tests/test_dependencies/mod.rs index f3fb27f4..2d08ad92 100644 --- a/crates/server/tests/test_dependencies/mod.rs +++ b/crates/server/tests/test_dependencies/mod.rs @@ -1,5 +1,42 @@ +// lib imports +use rstest::rstest; + +// local imports use koko::dependencies::get_dependencies; +#[rstest] +#[case("Apache-2.0")] +#[case("BSD-2-Clause")] +#[case("BSD-3-Clause")] +#[case("CC0-1.0")] +#[case("ISC")] +#[case("MIT")] +#[case("MPL-2.0")] +#[case("NCSA")] +#[case("Unicode-3.0")] +#[case("Unlicense")] +#[case("Zlib")] +fn test_individual_license_compatibility(#[case] license: &str) { + assert!( + is_license_compatible(license), + "License '{}' should be compatible", + license + ); +} + +#[rstest] +#[case("GPL-3.0")] +#[case("AGPL-3.0")] +#[case("Custom License")] +#[case("Proprietary")] +fn test_individual_license_incompatibility(#[case] license: &str) { + assert!( + !is_license_compatible(license), + "License '{}' should be incompatible", + license + ); +} + fn is_license_compatible(license: &str) -> bool { let compatible_licenses = vec![ // compatible: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses diff --git a/crates/server/tests/test_utils.rs b/crates/server/tests/test_utils.rs new file mode 100644 index 00000000..c6fa9d62 --- /dev/null +++ b/crates/server/tests/test_utils.rs @@ -0,0 +1,229 @@ +//! Shared test utilities to eliminate code duplication across test files. + +// standard imports +use std::sync::atomic::{AtomicU64, Ordering}; + +// lib imports +use rocket::http::{ContentType, Header, Status}; +use rocket::local::asynchronous::Client; +use serde_json::Value; + +// local imports +use koko::web; + +// Global counter to ensure unique database files across all tests +static GLOBAL_TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Enhanced test response structure with headers +pub struct TestResponse { + pub status: Status, + pub body: String, + pub headers: Vec>, +} + +/// Create a test client with an isolated database +pub async fn create_test_client(prefix: Option<&str>) -> Client { + // Set the test environment first + use koko::globals::CURRENT_ENV; + CURRENT_ENV.store(1, Ordering::SeqCst); + + // Use provided prefix or default to "test" + let prefix = prefix.unwrap_or("test"); + + // Create a unique database name for this test + let test_id = GLOBAL_TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let db_name = format!("{}_{}_{}.db", prefix, test_id, timestamp); + + // Ensure test_data directory exists + std::fs::create_dir_all("./test_data").expect("Failed to create test_data directory"); + + // Create the full database path + let db_path = format!("./test_data/{}", db_name); + + // Remove the database file if it exists from a previous run + if std::path::Path::new(&db_path).exists() { + std::fs::remove_file(&db_path).ok(); + } + + // Create a new rocket instance with the unique database path + let rocket = web::rocket_with_db_path(Some(db_path)); + let client = Client::tracked(rocket) + .await + .expect("Failed to create test client"); + + client +} + +/// Make an HTTP request to the Rocket application. +/// Returns either a simple tuple (Status, String) or enhanced TestResponse based on return_headers +pub async fn make_request( + client: Option<&Client>, + method: &str, + path: &str, + json_body: Option, + auth_header: Option, + expected_status: Option, + return_headers: Option, +) -> TestResponse { + let owned_client; + let client = match client { + Some(c) => c, + None => { + let rocket = web::rocket(); + owned_client = Client::tracked(rocket) + .await + .expect("Failed to launch web server"); + &owned_client + } + }; + + let mut request = match method.to_lowercase().as_str() { + "get" => client.get(path), + "post" => client.post(path), + "put" => client.put(path), + "delete" => client.delete(path), + "patch" => client.patch(path), + _ => panic!("Unsupported HTTP method: {}", method), + }; + + if let Some(json) = json_body { + request = request.header(ContentType::JSON).body(json.to_string()); + } + + if let Some(auth) = auth_header { + request = request.header(Header::new("Authorization", auth)); + } + + let response = request.dispatch().await; + let status = response.status(); + + // Assert expected status if provided + if let Some(expected) = expected_status { + assert_eq!( + status, expected, + "Expected status {} but got {}", + expected, status + ); + } + + let headers = if return_headers.unwrap_or(false) { + response + .headers() + .iter() + .map(|h| Header::new(h.name().to_string(), h.value().to_string())) + .collect() + } else { + Vec::new() + }; + + let body = response.into_string().await.unwrap_or_default(); + + TestResponse { + status, + body, + headers, + } +} + +/// Create a user via the API (useful for test setup) +pub async fn create_test_user( + client: &Client, + username: &str, + password: &str, + admin: bool, + pin: Option<&str>, + expected_status: Option, +) -> (Status, String) { + use serde_json::json; + + let mut user_data = json!({ + "username": username, + "password": password, + "admin": admin + }); + + if let Some(pin_value) = pin { + user_data + .as_object_mut() + .unwrap() + .insert("pin".to_string(), json!(pin_value)); + } + + let response = make_request( + Some(client), + "post", + "/create_user", + Some(user_data), + None, + expected_status, + Some(false), + ) + .await; + (response.status, response.body) +} + +/// Login a user and return the token +pub async fn login_user( + client: &Client, + username: &str, + password: &str, + expected_status: Option, +) -> Result { + use serde_json::json; + + let login_data = json!({ + "username": username, + "password": password + }); + + let response = make_request( + Some(client), + "post", + "/login", + Some(login_data), + None, + expected_status, + Some(false), + ) + .await; + + if response.status == Status::Ok { + let response_json: Value = serde_json::from_str(&response.body) + .map_err(|e| format!("Failed to parse login response: {}", e))?; + + response_json["token"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "Token not found in response".to_string()) + } else { + Err(format!( + "Login failed with status: {} - {}", + response.status, response.body + )) + } +} + +/// Create a user and login, returning the token +pub async fn create_and_login_user( + client: &Client, + username: &str, + password: &str, + admin: bool, + pin: Option<&str>, +) -> Result { + let (create_status, create_body) = + create_test_user(client, username, password, admin, pin, Some(Status::Ok)).await; + + if create_status != Status::Ok { + return Err(format!( + "Failed to create user: {} - {}", + create_status, create_body + )); + } + + login_user(client, username, password, Some(Status::Ok)).await +} diff --git a/crates/server/tests/test_web/mod.rs b/crates/server/tests/test_web/mod.rs index 2d6601f5..410bf55b 100644 --- a/crates/server/tests/test_web/mod.rs +++ b/crates/server/tests/test_web/mod.rs @@ -1,86 +1,51 @@ +// mod imports mod routes; +mod test_auth_routes; -use koko::web; -use rocket::http::{ContentType, Header, Status}; -use rocket::local::asynchronous::{Client, LocalResponse}; -use serde_json::Value; +// lib imports +use rocket::http::Status; -pub struct TestResponse { - pub status: Status, - pub body: String, - pub headers: Vec>, -} - -pub async fn test_request( - method: &str, - path: &'static str, - json: Option, - expected_status: Status, - client: Option<&Client>, -) -> TestResponse { - let client = match client { - Some(c) => c.to_owned(), - None => { - let rocket = web::rocket(); - &Client::tracked(rocket) - .await - .expect("Failed to launch web server") - } - }; - - let request = match method.to_lowercase().as_str() { - "get" => client.get(path), - "post" => client.post(path), - "put" => client.put(path), - "delete" => client.delete(path), - "patch" => client.patch(path), - _ => panic!("Unsupported HTTP method: {}", method), - }; - - let request = if let Some(json_value) = json { - request - .header(ContentType::JSON) - .body(json_value.to_string()) - } else { - request - }; - - let response = request.dispatch().await; - create_test_response(response, expected_status).await -} - -async fn create_test_response( - response: LocalResponse<'_>, - expected_status: Status, -) -> TestResponse { - assert_eq!(response.status(), expected_status); - - let status = response.status(); - let headers: Vec> = response - .headers() - .iter() - .map(|h| Header::new(h.name().to_string(), h.value().to_string())) - .collect(); - let body = response.into_string().await.unwrap_or_default(); - - TestResponse { - status, - body, - headers, - } -} +// test imports +use crate::test_utils::make_request; #[rocket::async_test] async fn test_swagger_ui_route() { - test_request("get", "/swagger-ui/", None, Status::SeeOther, None).await; + make_request( + None, + "get", + "/swagger-ui/", + None, + None, + Some(Status::SeeOther), + Some(false), + ) + .await; } #[rocket::async_test] async fn test_rapidoc_route() { - test_request("get", "/rapidoc/", None, Status::SeeOther, None).await; + make_request( + None, + "get", + "/rapidoc/", + None, + None, + Some(Status::SeeOther), + Some(false), + ) + .await; } #[rocket::async_test] async fn test_non_existent_route() { - test_request("get", "/non-existent", None, Status::NotFound, None).await; + make_request( + None, + "get", + "/non-existent", + None, + None, + Some(Status::NotFound), + Some(false), + ) + .await; } diff --git a/crates/server/tests/test_web/routes/auth.rs b/crates/server/tests/test_web/routes/auth.rs index 8aca1bf4..b262b1e6 100644 --- a/crates/server/tests/test_web/routes/auth.rs +++ b/crates/server/tests/test_web/routes/auth.rs @@ -1,49 +1,96 @@ // lib imports use rocket::http::Status; -use rstest::rstest; use serde_json::json; -use serial_test::serial; // test imports -use crate::fixtures; -use crate::test_web::test_request; - -#[rstest] -#[serial(db)] -#[tokio::test] -#[case::login_success("admin", "password123", Status::Ok)] -#[case::login_wrong_password("admin", "wrong", Status::Unauthorized)] -#[case::login_non_existent_user("nonexistent", "wrong", Status::Unauthorized)] -async fn test_login( - #[future] - #[from(fixtures::db_fixture)] - #[with(true)] - db_future: fixtures::TestDb, - #[case] username: &str, - #[case] password: &str, - #[case] expected_status: Status, -) { - let db = db_future.await; - - // Test login - let response = test_request( +use crate::test_utils::{ + create_and_login_user, + create_test_client, + create_test_user, + make_request, +}; + +#[rocket::async_test] +async fn test_login_success() { + let client = create_test_client(Some("auth_routes_login_success")).await; + + // Create and login user using the helper function + let token = create_and_login_user(&client, "admin", "password123", true, Some("1234")) + .await + .expect("Should create and login user successfully"); + + // Verify we got a valid token + assert!(!token.is_empty()); +} + +#[rocket::async_test] +async fn test_login_wrong_password() { + let client = create_test_client(Some("auth_routes_wrong_password")).await; + + // Create a user + let (_status, _) = create_test_user( + &client, + "admin", + "password123", + true, + Some("1234"), + Some(Status::Ok), + ) + .await; + + // Test login with the wrong password + let login_data = json!({ + "username": "admin", + "password": "wrong" + }); + + let _response = make_request( + Some(&client), "post", "/login", - Some(json!({ - "username": username, - "password": password, - })), - expected_status, - Some(&db.client), + Some(login_data), + None, + Some(Status::Unauthorized), + Some(false), ) .await; +} + +#[rocket::async_test] +async fn test_login_non_existent_user() { + let client = create_test_client(Some("auth_routes_non_existent")).await; - if expected_status == Status::Ok { - assert!(response.body.contains("token")); - } + // Test login with a non-existent user (no users created) + let login_data = json!({ + "username": "nonexistent", + "password": "wrong" + }); + + let _response = make_request( + Some(&client), + "post", + "/login", + Some(login_data), + None, + Some(Status::Unauthorized), + Some(false), + ) + .await; } #[rocket::async_test] async fn test_logout_route() { - test_request("get", "/logout", None, Status::Ok, None).await; + let client = create_test_client(Some("auth_routes_logout")).await; + + let response = make_request( + Some(&client), + "get", + "/logout", + None, + None, + Some(Status::Ok), + Some(false), + ) + .await; + assert_eq!(response.body, "Logout Page"); } diff --git a/crates/server/tests/test_web/routes/common.rs b/crates/server/tests/test_web/routes/common.rs index 10fc6be9..8c37d376 100644 --- a/crates/server/tests/test_web/routes/common.rs +++ b/crates/server/tests/test_web/routes/common.rs @@ -1,10 +1,22 @@ -use crate::test_web::test_request; - +// lib imports use rocket::http::Status; +// test imports +use crate::test_utils::{create_test_client, make_request}; + #[rocket::async_test] async fn test_root_route() { - let response = test_request("get", "/", None, Status::Ok, None).await; + let client = create_test_client(Some("common_routes")).await; + let response = make_request( + Some(&client), + "get", + "/", + None, + None, + Some(Status::Ok), + Some(false), + ) + .await; assert_eq!(response.body, "Welcome to Koko!"); } diff --git a/crates/server/tests/test_web/routes/dependencies.rs b/crates/server/tests/test_web/routes/dependencies.rs index 02153859..cbb05976 100644 --- a/crates/server/tests/test_web/routes/dependencies.rs +++ b/crates/server/tests/test_web/routes/dependencies.rs @@ -1,13 +1,24 @@ -use crate::test_web::test_request; - +// lib imports use rocket::http::Status; use rocket::serde::json::{Value, serde_json}; +// test imports +use crate::test_utils::{TestResponse, make_request}; + #[rocket::async_test] async fn test_get_dependencies_route() { - let response = test_request("get", "/dependencies", None, Status::Ok, None).await; + let response: TestResponse = make_request( + None, + "get", + "/dependencies", + None, + None, + Some(Status::Ok), + Some(true), + ) + .await; - // ensure response is a json list of dictionaries, and each dictionary has the keys name, + // ensure the response is a JSON list of dictionaries, and each dictionary has the key name, // version, and license let body = response.body; let json: Value = serde_json::from_str(&body).unwrap(); diff --git a/crates/server/tests/test_web/routes/user.rs b/crates/server/tests/test_web/routes/user.rs index ee8fc074..a3cc569a 100644 --- a/crates/server/tests/test_web/routes/user.rs +++ b/crates/server/tests/test_web/routes/user.rs @@ -1,54 +1,55 @@ // lib imports use rocket::http::Status; use rstest::rstest; -use serde_json::json; -use serial_test::serial; // test imports -use crate::fixtures; -use crate::test_web::test_request; +use crate::test_utils::{create_test_client, create_test_user}; #[rstest] -#[serial(db)] -#[tokio::test] -async fn test_create_first_user( - #[future] - #[from(fixtures::db_fixture)] - #[with(true)] - db_future: fixtures::TestDb -) { - db_future.await; - - // nothing to do, the fixture handles creating the first user -} - -#[rstest] -#[serial(db)] -#[tokio::test] -#[case::create_user_requires_auth("testuser", "password123", false, Status::Unauthorized)] -async fn test_create_subsequent_user_requires_auth( - #[future] - #[from(fixtures::db_fixture)] - #[with(true)] - db_future: fixtures::TestDb, +#[case("admin", "password123", true, Some("1234"))] +#[case("user", "userpass456", false, None)] +#[case("power-user", "complex!Pass@789", false, Some("9876"))] +async fn test_create_first_user_scenarios( #[case] username: &str, #[case] password: &str, #[case] admin: bool, - #[case] expected_status: Status, + #[case] pin: Option<&str>, ) { - let db = db_future.await; + let client = create_test_client(Some(&format!("user_routes_first_{}", username))).await; + + // Create the first user (should not require authentication) + let (status, body) = + create_test_user(&client, username, password, admin, pin, Some(Status::Ok)).await; + + assert_eq!(status, Status::Ok); + assert_eq!(body, "User created"); +} + +#[rocket::async_test] +async fn test_create_user_requires_auth() { + let client = create_test_client(Some("user_routes_requires_auth")).await; + + // First, create a user to populate the database + let (status, _) = create_test_user( + &client, + "admin", + "password123", + true, + Some("1234"), + Some(Status::Ok), + ) + .await; + assert_eq!(status, Status::Ok); - // Try to create second user without auth - test_request( - "post", - "/create_user", - Some(json!({ - "username": username, - "password": password, - "admin": admin - })), - expected_status, - Some(&db.client), + // Try to create a second user without auth (should fail) + let (status, _) = create_test_user( + &client, + "test_user", + "password123", + false, + None, + Some(Status::Unauthorized), ) .await; + assert_eq!(status, Status::Unauthorized); } diff --git a/crates/server/tests/test_web/test_auth_routes.rs b/crates/server/tests/test_web/test_auth_routes.rs new file mode 100644 index 00000000..45722f7b --- /dev/null +++ b/crates/server/tests/test_web/test_auth_routes.rs @@ -0,0 +1,360 @@ +//! Integration tests for authentication routes. + +// lib imports +use rocket::http::Status; +use rstest::rstest; +use serde_json::json; + +// test imports +use crate::test_utils::{ + create_and_login_user, + create_test_client, + create_test_user, + make_request, +}; + +#[rocket::async_test] +async fn test_create_first_user_no_auth_required() { + let client = create_test_client(Some("auth_first_user")).await; + + let user_data = json!({ + "username": "admin", + "password": "admin123", + "admin": true + }); + + let response = make_request( + Some(&client), + "post", + "/create_user", + Some(user_data), + None, + Some(Status::Ok), + Some(false), + ) + .await; + assert_eq!(response.body, "User created"); +} + +#[rstest] +#[case("testuser", "testpass123", false, None, "regular user")] +#[case("admin", "adminpass456", true, None, "admin user")] +#[case("userpin", "pass789", false, Some("1234"), "user with PIN")] +#[case("admin_pin", "adminpass", true, Some("5678"), "admin user with PIN")] +async fn test_login_with_valid_credentials( + #[case] username: &str, + #[case] password: &str, + #[case] admin: bool, + #[case] pin: Option<&str>, + #[case] _description: &str, +) { + let client = create_test_client(Some(&format!("auth_login_valid_{}", username))).await; + + // Create and login user using the helper function + let token = create_and_login_user(&client, username, password, admin, pin) + .await + .expect("Should create and login user successfully"); + + // Verify we got a valid token + assert!(!token.is_empty()); +} + +#[rstest] +#[case("nonexistent", "wrongpass", "non-existent user")] +#[case("", "", "empty credentials")] +#[case("user", "", "empty password")] +#[case("", "password", "empty username")] +async fn test_login_with_invalid_credentials( + #[case] username: &str, + #[case] password: &str, + #[case] _description: &str, +) { + let client = create_test_client(Some(&format!( + "auth_login_invalid_{}", + username.replace("", "empty") + ))) + .await; + + // Try to login with invalid credentials + let login_data = json!({ + "username": username, + "password": password + }); + + make_request( + Some(&client), + "post", + "/login", + Some(login_data), + None, + Some(Status::Unauthorized), + Some(false), + ) + .await; +} + +#[rstest] +#[case("testuser2", "correctpass", "wrongpass")] +#[case("admin", "admin123", "notadmin")] +#[case("user", "mypassword", "yourpassword")] +async fn test_login_with_wrong_password( + #[case] username: &str, + #[case] correct_password: &str, + #[case] wrong_password: &str, +) { + let client = create_test_client(Some(&format!("auth_wrong_password_{}", username))).await; + + // Create a user + let (status, _) = create_test_user( + &client, + username, + correct_password, + false, + None, + Some(Status::Ok), + ) + .await; + assert_eq!(status, Status::Ok); + + // Try to login with the wrong password + let login_data = json!({ + "username": username, + "password": wrong_password + }); + + let response = make_request( + Some(&client), + "post", + "/login", + Some(login_data), + None, + Some(Status::Unauthorized), + Some(false), + ) + .await; + assert_eq!(response.status, Status::Unauthorized); +} + +#[rstest] +#[case("/jwt_test", "GET")] +#[case("/admin_test", "GET")] +async fn test_jwt_protected_routes_without_token( + #[case] route: &str, + #[case] method: &str, +) { + let client = create_test_client(Some("auth_no_token")).await; + + let response = make_request( + Some(&client), + &method.to_lowercase(), + route, + None, + None, + Some(Status::Unauthorized), + Some(false), + ) + .await; + assert_eq!(response.status, Status::Unauthorized); +} + +#[rstest] +#[case("Bearer invalid_token")] +#[case("Bearer ")] +#[case("invalid_token")] +#[case("Bearer malformed.jwt.token")] +async fn test_jwt_protected_routes_with_invalid_token(#[case] auth_header: &str) { + let client = create_test_client(Some("auth_invalid_token")).await; + + let _response = make_request( + Some(&client), + "get", + "/jwt_test", + None, + Some(auth_header.to_string()), + Some(Status::Unauthorized), + Some(false), + ) + .await; + // Status assertion is now handled by expected_status parameter +} + +#[rocket::async_test] +async fn test_jwt_protected_route_with_valid_token() { + let client = create_test_client(Some("auth_valid_token")).await; + + // Create and login user to get a token + let token = create_and_login_user(&client, "jwtuser", "jwtpass123", false, None) + .await + .expect("Should create and login user successfully"); + + // Use the token to access a protected route + let auth_header = format!("Bearer {}", token); + let response = make_request( + Some(&client), + "get", + "/jwt_test", + None, + Some(auth_header), + Some(Status::Ok), + Some(false), + ) + .await; + assert_eq!(response.body, "Protected Page"); +} + +#[rocket::async_test] +async fn test_admin_route_with_non_admin_user() { + let client = create_test_client(Some("auth_non_admin")).await; + + // Create and login non-admin user + let token = create_and_login_user(&client, "regularuser", "regularpass", false, None) + .await + .expect("Should create and login user successfully"); + + // Try to access the admin route + let auth_header = format!("Bearer {}", token); + let _response = make_request( + Some(&client), + "get", + "/admin_test", + None, + Some(auth_header), + Some(Status::Forbidden), + Some(false), + ) + .await; + // Status assertion is now handled by expected_status parameter +} + +#[rocket::async_test] +async fn test_admin_route_with_admin_user() { + let client = create_test_client(Some("auth_admin")).await; + + // Create and login admin user + let token = create_and_login_user(&client, "adminuser", "adminpass", true, None) + .await + .expect("Should create and login admin user successfully"); + + // Access admin route + let auth_header = format!("Bearer {}", token); + let response = make_request( + Some(&client), + "get", + "/admin_test", + None, + Some(auth_header), + Some(Status::Ok), + Some(false), + ) + .await; + assert_eq!(response.body, "Admin only content"); +} + +#[rocket::async_test] +async fn test_create_user_requires_admin_after_first_user() { + let client = create_test_client(Some("auth_admin_required")).await; + + // Create first user (admin) + let (status, _) = create_test_user( + &client, + "firstadmin", + "adminpass", + true, + None, + Some(Status::Ok), + ) + .await; + assert_eq!(status, Status::Ok); + + // Try to create a second user without authentication (should fail) + let (status, _) = create_test_user( + &client, + "seconduser", + "userpass", + false, + None, + Some(Status::Unauthorized), + ) + .await; + assert_eq!(status, Status::Unauthorized); +} + +#[rocket::async_test] +async fn test_create_user_with_pin() { + let client = create_test_client(Some("auth_with_pin")).await; + + // Create a user with PIN + let (status, body) = create_test_user( + &client, + "pinuser", + "userpass", + false, + Some("1234"), + Some(Status::Ok), + ) + .await; + assert_eq!(status, Status::Ok); + assert_eq!(body, "User created"); +} + +#[rocket::async_test] +async fn test_create_user_with_invalid_pin() { + let client = create_test_client(Some("auth_invalid_pin")).await; + + // Try to create user with invalid PIN (too short) + let invalid_pin_data = json!({ + "username": "badpinuser", + "password": "userpass", + "pin": "123", + "admin": false + }); + + let _response = make_request( + Some(&client), + "post", + "/create_user", + Some(invalid_pin_data), + None, + Some(Status::BadRequest), + Some(false), + ) + .await; + + // Try to create user with invalid PIN (too long) + let invalid_pin_data = json!({ + "username": "badpinuser2", + "password": "userpass", + "pin": "1234567", + "admin": false + }); + + let _response = make_request( + Some(&client), + "post", + "/create_user", + Some(invalid_pin_data), + None, + Some(Status::BadRequest), + Some(false), + ) + .await; + + // Try to create user with non-numeric PIN + let invalid_pin_data = json!({ + "username": "badpinuser3", + "password": "userpass", + "pin": "abcd", + "admin": false + }); + + let _response = make_request( + Some(&client), + "post", + "/create_user", + Some(invalid_pin_data), + None, + Some(Status::BadRequest), + Some(false), + ) + .await; +}