diff --git a/Cargo.lock b/Cargo.lock index fd439f6..e04a2c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -670,6 +670,31 @@ dependencies = [ "serde_with", ] +[[package]] +name = "bon" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2529c31017402be841eb45892278a6c21a000c0a17643af326c73a73f83f0fb" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d82020dadcb845a345591863adb65d74fa8dc5c18a0b6d408470e13b7adc7005" +dependencies = [ + "darling 0.21.3", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "built" version = "0.7.7" @@ -1075,8 +1100,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -1093,13 +1128,38 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn", ] @@ -3045,6 +3105,7 @@ dependencies = [ "axum", "axum-cookie", "axum-test", + "bon", "bytes 1.10.1", "chrono", "dashmap", @@ -4344,7 +4405,7 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", diff --git a/pacman-server/Cargo.toml b/pacman-server/Cargo.toml index 5418575..a519a22 100644 --- a/pacman-server/Cargo.toml +++ b/pacman-server/Cargo.toml @@ -63,3 +63,4 @@ anyhow = "1" axum-test = "18.1.0" pretty_assertions = "1.4.1" testcontainers = "0.25.0" +bon = "3.7.2" diff --git a/pacman-server/tests/basics.rs b/pacman-server/tests/basics.rs new file mode 100644 index 0000000..ac7036c --- /dev/null +++ b/pacman-server/tests/basics.rs @@ -0,0 +1,17 @@ +mod common; + +use pretty_assertions::assert_eq; + +use crate::common::{test_context, TestContext}; + +// A basic test of all the server's routes that aren't covered by other tests. +#[tokio::test] +async fn test_basic_routes() { + let routes = vec!["/", "/auth/providers"]; + + for route in routes { + let TestContext { server, .. } = test_context().use_database(false).call().await; + let response = server.get(route).await; + assert_eq!(response.status_code(), 200); + } +} diff --git a/pacman-server/tests/common/mod.rs b/pacman-server/tests/common/mod.rs index 1c769f0..77acfd9 100644 --- a/pacman-server/tests/common/mod.rs +++ b/pacman-server/tests/common/mod.rs @@ -1,10 +1,11 @@ -use axum::Router; +use axum_test::TestServer; +use bon::builder; use pacman_server::{ app::{create_router, AppState}, auth::AuthRegistry, config::Config, }; -use std::sync::Arc; +use std::sync::{Arc, Once}; use testcontainers::{ core::{IntoContainerPort, WaitFor}, runners::AsyncRunner, @@ -12,56 +13,87 @@ use testcontainers::{ }; use tokio::sync::Notify; +static CRYPTO_INIT: Once = Once::new(); + /// Test configuration for integration tests -pub struct TestConfig { - pub database_url: Option, - pub container: Option>, +#[allow(dead_code)] +pub struct TestContext { pub config: Config, + pub server: TestServer, + pub app_state: AppState, + // Optional database + pub container: Option>, } -impl TestConfig { - /// Create a test configuration with a test database - pub async fn new() -> Self { - Self::new_with_database(true).await - } - - /// Create a test configuration with optional database setup - pub async fn new_with_database(use_database: bool) -> Self { +#[builder] +pub async fn test_context(use_database: bool) -> TestContext { + CRYPTO_INIT.call_once(|| { rustls::crypto::ring::default_provider() .install_default() .expect("Failed to install default crypto provider"); + }); - let (database_url, container) = if use_database { - let (url, container) = setup_test_database("testdb", "testuser", "testpass").await; - (Some(url), Some(container)) - } else { - (None, None) - }; + let (database_url, container) = if use_database { + let (url, container) = setup_test_database("testdb", "testuser", "testpass").await; + (Some(url), Some(container)) + } else { + (None, None) + }; - let config = Config { - database_url: database_url - .clone() - .unwrap_or_else(|| "postgresql://dummy:dummy@localhost:5432/dummy?sslmode=disable".to_string()), - discord_client_id: "test_discord_client_id".to_string(), - discord_client_secret: "test_discord_client_secret".to_string(), - github_client_id: "test_github_client_id".to_string(), - github_client_secret: "test_github_client_secret".to_string(), - s3_access_key: "test_s3_access_key".to_string(), - s3_secret_access_key: "test_s3_secret_access_key".to_string(), - s3_bucket_name: "test_bucket".to_string(), - s3_public_base_url: "https://test.example.com".to_string(), - port: 0, // Will be set by test server - host: "127.0.0.1".parse().unwrap(), - shutdown_timeout_seconds: 5, - public_base_url: "http://localhost:3000".to_string(), - jwt_secret: "test_jwt_secret_key_for_testing_only".to_string(), - }; + let config = Config { + database_url: database_url.clone().unwrap_or_default(), + discord_client_id: "test_discord_client_id".to_string(), + discord_client_secret: "test_discord_client_secret".to_string(), + github_client_id: "test_github_client_id".to_string(), + github_client_secret: "test_github_client_secret".to_string(), + s3_access_key: "test_s3_access_key".to_string(), + s3_secret_access_key: "test_s3_secret_access_key".to_string(), + s3_bucket_name: "test_bucket".to_string(), + s3_public_base_url: "https://test.example.com".to_string(), + port: 0, // Will be set by test server + host: "127.0.0.1".parse().unwrap(), + shutdown_timeout_seconds: 5, + public_base_url: "http://localhost:3000".to_string(), + jwt_secret: "test_jwt_secret_key_for_testing_only".to_string(), + }; - Self { - database_url, - container, - config, - } + let db = if use_database { + let db = pacman_server::data::pool::create_pool(use_database, &database_url.unwrap(), 5).await; + + // Run migrations + sqlx::migrate!("./migrations") + .run(&db) + .await + .expect("Failed to run database migrations"); + + db + } else { + // Create a dummy database pool that will fail gracefully + let dummy_url = "postgresql://dummy:dummy@localhost:5432/dummy?sslmode=disable"; + pacman_server::data::pool::create_pool(false, dummy_url, 1).await + }; + + // Create auth registry + let auth = AuthRegistry::new(&config).expect("Failed to create auth registry"); + + // Create app state + let notify = Arc::new(Notify::new()); + let app_state = AppState::new_with_database(config.clone(), auth, db, notify, use_database).await; + + // Set health status based on database usage + { + let mut health = app_state.health.write().await; + health.set_migrations(use_database); + health.set_database(use_database); + } + + let router = create_router(app_state.clone()); + + TestContext { + server: TestServer::new(router).unwrap(), + app_state, + config, + container, } } @@ -85,53 +117,3 @@ async fn setup_test_database(db: &str, user: &str, password: &str) -> (String, C container, ) } - -/// Create a test app state with database and auth registry -pub async fn create_test_app_state(test_config: &TestConfig) -> AppState { - create_test_app_state_with_database(test_config, true).await -} - -/// Create a test app state with optional database setup -pub async fn create_test_app_state_with_database(test_config: &TestConfig, use_database: bool) -> AppState { - let db = if use_database { - // Create database pool - let db_url = test_config - .database_url - .as_ref() - .expect("Database URL required when use_database is true"); - let db = pacman_server::data::pool::create_pool(use_database, db_url, 5).await; - - // Run migrations - sqlx::migrate!("./migrations") - .run(&db) - .await - .expect("Failed to run database migrations"); - - db - } else { - // Create a dummy database pool that will fail gracefully - let dummy_url = "postgresql://dummy:dummy@localhost:5432/dummy?sslmode=disable"; - pacman_server::data::pool::create_pool(false, dummy_url, 1).await - }; - - // Create auth registry - let auth = AuthRegistry::new(&test_config.config).expect("Failed to create auth registry"); - - // Create app state - let notify = Arc::new(Notify::new()); - let app_state = AppState::new_with_database(test_config.config.clone(), auth, db, notify, use_database).await; - - // Set health status based on database usage - { - let mut health = app_state.health.write().await; - health.set_migrations(use_database); - health.set_database(use_database); - } - - app_state -} - -/// Create a test router with the given app state -pub fn create_test_router(app_state: AppState) -> Router { - create_router(app_state) -} diff --git a/pacman-server/tests/health.rs b/pacman-server/tests/health.rs new file mode 100644 index 0000000..903f937 --- /dev/null +++ b/pacman-server/tests/health.rs @@ -0,0 +1,26 @@ +mod common; + +use pretty_assertions::assert_eq; + +use crate::common::{test_context, TestContext}; + +/// Test health endpoint functionality with real database connectivity +#[tokio::test] +async fn test_health_endpoint() { + let TestContext { server, container, .. } = test_context().use_database(true).call().await; + + // First, verify health endpoint works when database is healthy + let response = server.get("/health").await; + assert_eq!(response.status_code(), 200); + let health_json: serde_json::Value = response.json(); + assert_eq!(health_json["ok"], true); + + // Now kill the database container to simulate database failure + drop(container); + + // Now verify health endpoint reports bad health + let response = server.get("/health?force").await; + assert_eq!(response.status_code(), 503); // SERVICE_UNAVAILABLE + let health_json: serde_json::Value = response.json(); + assert_eq!(health_json["ok"], false); +} diff --git a/pacman-server/tests/oauth_integration.rs b/pacman-server/tests/oauth.rs similarity index 54% rename from pacman-server/tests/oauth_integration.rs rename to pacman-server/tests/oauth.rs index 94672e1..74fa2bc 100644 --- a/pacman-server/tests/oauth_integration.rs +++ b/pacman-server/tests/oauth.rs @@ -1,89 +1,12 @@ -use axum_test::TestServer; -use mockall::predicate::*; use pretty_assertions::assert_eq; mod common; -use common::{create_test_app_state, create_test_app_state_with_database, create_test_router, TestConfig}; - -/// Setup function with optional database -async fn setup_test_server(use_database: bool) -> TestServer { - let test_config = TestConfig::new_with_database(use_database).await; - let app_state = create_test_app_state_with_database(&test_config, use_database).await; - let router = create_test_router(app_state); - TestServer::new(router).unwrap() -} - -/// Test basic endpoints functionality -#[tokio::test] -async fn test_basic_endpoints() { - let server = setup_test_server(false).await; - - // Test root endpoint - let response = server.get("/").await; - assert_eq!(response.status_code(), 200); -} - -/// Test health endpoint functionality with real database connectivity -#[tokio::test] -async fn test_health_endpoint() { - let test_config = TestConfig::new().await; - let app_state = create_test_app_state(&test_config).await; - - let router = create_test_router(app_state.clone()); - let server = TestServer::new(router).unwrap(); - - // First, verify health endpoint works when database is healthy - let response = server.get("/health").await; - assert_eq!(response.status_code(), 200); - let health_json: serde_json::Value = response.json(); - assert_eq!(health_json["ok"], true); - - // Now kill the database container to simulate database failure - drop(test_config.container); - - // Now verify health endpoint reports bad health - let response = server.get("/health?force").await; - assert_eq!(response.status_code(), 503); // SERVICE_UNAVAILABLE - let health_json: serde_json::Value = response.json(); - assert_eq!(health_json["ok"], false); -} - -/// Test OAuth provider listing and configuration -#[tokio::test] -async fn test_oauth_provider_configuration() { - let server = setup_test_server(false).await; - - // Test providers list endpoint - let response = server.get("/auth/providers").await; - assert_eq!(response.status_code(), 200); - let providers: Vec = response.json(); - assert_eq!(providers.len(), 2); // Should have GitHub and Discord providers - - // Verify provider structure - let provider_ids: Vec<&str> = providers.iter().map(|p| p["id"].as_str().unwrap()).collect(); - assert!(provider_ids.contains(&"github")); - assert!(provider_ids.contains(&"discord")); - - // Verify provider details - for provider in providers { - let id = provider["id"].as_str().unwrap(); - let name = provider["name"].as_str().unwrap(); - let active = provider["active"].as_bool().unwrap(); - - assert!(active, "Provider {} should be active", id); - - match id { - "github" => assert_eq!(name, "GitHub"), - "discord" => assert_eq!(name, "Discord"), - _ => panic!("Unknown provider: {}", id), - } - } -} +use crate::common::{test_context, TestContext}; /// Test OAuth authorization flows #[tokio::test] async fn test_oauth_authorization_flows() { - let server = setup_test_server(false).await; + let TestContext { server, .. } = test_context().use_database(false).call().await; // Test OAuth authorize endpoint (should redirect) let response = server.get("/auth/github").await; @@ -101,45 +24,17 @@ async fn test_oauth_authorization_flows() { /// Test OAuth callback handling #[tokio::test] async fn test_oauth_callback_handling() { - let server = setup_test_server(false).await; + let TestContext { server, .. } = test_context().use_database(false).call().await; // Test OAuth callback with missing parameters (should fail gracefully) let response = server.get("/auth/github/callback").await; assert_eq!(response.status_code(), 400); // Bad request for missing code/state } -/// Test session management endpoints -#[tokio::test] -async fn test_session_management() { - let server = setup_test_server(false).await; - - // Test logout endpoint (should redirect) - let response = server.get("/logout").await; - assert_eq!(response.status_code(), 302); // Redirect to home - - // Test profile endpoint without session (should be unauthorized) - let response = server.get("/profile").await; - assert_eq!(response.status_code(), 401); // Unauthorized without session -} - -/// Test that verifies database operations work correctly -#[tokio::test] -async fn test_database_operations() { - let server = setup_test_server(true).await; - - // Act: Test health endpoint to verify database connectivity - let response = server.get("/health").await; - - // Assert: Health should be OK, indicating database is connected and migrations ran - assert_eq!(response.status_code(), 200); - let health_json: serde_json::Value = response.json(); - assert_eq!(health_json["ok"], true); -} - /// Test OAuth authorization flow #[tokio::test] async fn test_oauth_authorization_flow() { - let _server = setup_test_server(false).await; + let TestContext { server, .. } = test_context().use_database(false).call().await; // TODO: Test that the OAuth authorize handler redirects to the provider's authorization page for valid providers // TODO: Test that the OAuth authorize handler returns an error for unknown providers @@ -149,7 +44,7 @@ async fn test_oauth_authorization_flow() { /// Test OAuth callback validation #[tokio::test] async fn test_oauth_callback_validation() { - let _server = setup_test_server(false).await; + let TestContext { server, .. } = test_context().use_database(false).call().await; // TODO: Test that the OAuth callback handler validates the provider exists before processing // TODO: Test that the OAuth callback handler returns an error when the provider returns an OAuth error @@ -160,7 +55,7 @@ async fn test_oauth_callback_validation() { /// Test OAuth callback processing #[tokio::test] async fn test_oauth_callback_processing() { - let _server = setup_test_server(false).await; + let TestContext { server, .. } = test_context().use_database(false).call().await; // TODO: Test that the OAuth callback handler exchanges the authorization code for user information successfully // TODO: Test that the OAuth callback handler handles provider callback errors gracefully @@ -172,7 +67,7 @@ async fn test_oauth_callback_processing() { /// Test account linking flow #[tokio::test] async fn test_account_linking_flow() { - let _server = setup_test_server(false).await; + let TestContext { server, .. } = test_context().use_database(false).call().await; // TODO: Test that the OAuth callback handler links a new provider to an existing user when link intent is present and session is valid // TODO: Test that the OAuth callback handler redirects to profile after successful account linking @@ -182,7 +77,7 @@ async fn test_account_linking_flow() { /// Test new user registration #[tokio::test] async fn test_new_user_registration() { - let _server = setup_test_server(false).await; + let TestContext { server, .. } = test_context().use_database(false).call().await; // TODO: Test that the OAuth callback handler creates a new user account when no existing user is found // TODO: Test that the OAuth callback handler requires an email address for all sign-ins @@ -192,7 +87,7 @@ async fn test_new_user_registration() { /// Test existing user sign-in #[tokio::test] async fn test_existing_user_sign_in() { - let _server = setup_test_server(false).await; + let TestContext { server, .. } = test_context().use_database(false).call().await; // TODO: Test that the OAuth callback handler allows sign-in when the provider is already linked to an existing user // TODO: Test that the OAuth callback handler requires explicit linking when a user with the same email exists and has other providers linked @@ -202,7 +97,7 @@ async fn test_existing_user_sign_in() { /// Test avatar processing #[tokio::test] async fn test_avatar_processing() { - let _server = setup_test_server(false).await; + let TestContext { server, .. } = test_context().use_database(false).call().await; // TODO: Test that the OAuth callback handler processes user avatars asynchronously without blocking the response // TODO: Test that the OAuth callback handler handles avatar processing errors gracefully @@ -211,7 +106,7 @@ async fn test_avatar_processing() { /// Test profile access #[tokio::test] async fn test_profile_access() { - let _server = setup_test_server(false).await; + let TestContext { server, .. } = test_context().use_database(false).call().await; // TODO: Test that the profile handler returns user information when a valid session exists // TODO: Test that the profile handler returns an error when no session cookie is present @@ -223,7 +118,7 @@ async fn test_profile_access() { /// Test logout functionality #[tokio::test] async fn test_logout_functionality() { - let _server = setup_test_server(false).await; + let TestContext { server, .. } = test_context().use_database(false).call().await; // TODO: Test that the logout handler clears the session if a session was there // TODO: Test that the logout handler removes the session from memory storage @@ -234,7 +129,7 @@ async fn test_logout_functionality() { /// Test provider configuration #[tokio::test] async fn test_provider_configuration() { - let _server = setup_test_server(false).await; + let TestContext { server, .. } = test_context().use_database(false).call().await; // TODO: Test that the providers list handler returns all configured OAuth providers // TODO: Test that the providers list handler includes provider status (active/inactive) diff --git a/pacman-server/tests/sessions.rs b/pacman-server/tests/sessions.rs new file mode 100644 index 0000000..7b7d6f0 --- /dev/null +++ b/pacman-server/tests/sessions.rs @@ -0,0 +1,18 @@ +mod common; +use crate::common::{test_context, TestContext}; + +use pretty_assertions::assert_eq; + +/// Test session management endpoints +#[tokio::test] +async fn test_session_management() { + let TestContext { server, .. } = test_context().use_database(true).call().await; + + // Test logout endpoint (should redirect) + let response = server.get("/logout").await; + assert_eq!(response.status_code(), 302); // Redirect to home + + // Test profile endpoint without session (should be unauthorized) + let response = server.get("/profile").await; + assert_eq!(response.status_code(), 401); // Unauthorized without session +}