diff --git a/Justfile b/Justfile index 8749543..e7aa43c 100644 --- a/Justfile +++ b/Justfile @@ -35,3 +35,5 @@ dev: # Build and preview frontend (web::up) up: @just web::up + +alias vcpkg := pacman::vcpkg diff --git a/pacman-server/src/app.rs b/pacman-server/src/app.rs index f0a61cf..44ef8e8 100644 --- a/pacman-server/src/app.rs +++ b/pacman-server/src/app.rs @@ -18,10 +18,16 @@ use crate::{auth::AuthRegistry, config::Config, image::ImageStorage, routes}; pub struct Health { migrations: bool, database: bool, + /// Whether database is configured at all + database_enabled: bool, } impl Health { pub fn ok(&self) -> bool { + // If database is not enabled, we're healthy as long as we don't require it + if !self.database_enabled { + return true; + } self.migrations && self.database } @@ -32,6 +38,10 @@ impl Health { pub fn set_database(&mut self, ok: bool) { self.database = ok; } + + pub fn set_database_enabled(&mut self, enabled: bool) { + self.database_enabled = enabled; + } } #[derive(Clone)] @@ -42,33 +52,45 @@ pub struct AppState { pub jwt_decoding_key: Arc, pub db: PgPool, pub health: Arc>, - pub image_storage: Arc, + pub image_storage: Option>, pub healthchecker_task: Arc>>>, + /// Whether the database is actually configured (vs SQLite in-memory fallback) + pub database_configured: bool, } impl AppState { pub async fn new(config: Config, auth: AuthRegistry, db: PgPool, shutdown_notify: Arc) -> Self { - Self::new_with_database(config, auth, db, shutdown_notify, true).await + Self::new_with_options(config, auth, db, shutdown_notify, true).await } - pub async fn new_with_database( + pub async fn new_with_options( config: Config, auth: AuthRegistry, db: PgPool, shutdown_notify: Arc, - use_database: bool, + use_database_healthcheck: bool, ) -> Self { let jwt_secret = config.jwt_secret.clone(); + let database_configured = config.database.is_some(); - // Initialize image storage - let image_storage = match ImageStorage::from_config(&config) { - Ok(storage) => Arc::new(storage), - Err(e) => { - tracing::warn!(error = %e, "Failed to initialize image storage, avatar processing will be disabled"); - // Create a dummy storage that will fail gracefully - Arc::new(ImageStorage::new(&config, "dummy").unwrap_or_else(|_| panic!("Failed to create dummy image storage"))) - } - }; + // Initialize image storage only if S3 is configured + let image_storage = config + .s3 + .as_ref() + .and_then(|s3_config| match ImageStorage::from_config(s3_config) { + Ok(storage) => { + tracing::info!("Image storage initialized"); + Some(Arc::new(storage)) + } + Err(e) => { + tracing::warn!(error = %e, "Failed to initialize image storage, avatar processing will be disabled"); + None + } + }); + + if image_storage.is_none() && config.s3.is_none() { + tracing::info!("S3 not configured, image storage disabled"); + } let app_state = Self { auth: Arc::new(auth), @@ -79,10 +101,17 @@ impl AppState { health: Arc::new(RwLock::new(Health::default())), image_storage, healthchecker_task: Arc::new(RwLock::new(None)), + database_configured, }; - // Start the healthchecker task only if database is being used - if use_database { + // Set database enabled status + { + let mut h = app_state.health.write().await; + h.set_database_enabled(database_configured); + } + + // Start the healthchecker task only if database healthcheck is enabled + if use_database_healthcheck && database_configured { let health_state = app_state.health.clone(); let db_pool = app_state.db.clone(); let healthchecker_task = app_state.healthchecker_task.clone(); @@ -131,6 +160,9 @@ impl AppState { /// Force an immediate health check (debug mode only) pub async fn check_health(&self) -> bool { + if !self.database_configured { + return true; + } let ok = sqlx::query("SELECT 1").execute(&self.db).await.is_ok(); let mut h = self.health.write().await; h.set_database(ok); diff --git a/pacman-server/src/auth/mod.rs b/pacman-server/src/auth/mod.rs index 8b82bf7..6e524d5 100644 --- a/pacman-server/src/auth/mod.rs +++ b/pacman-server/src/auth/mod.rs @@ -21,38 +21,53 @@ pub struct AuthRegistry { } impl AuthRegistry { + /// Create a new AuthRegistry with providers based on configuration. + /// Only providers with complete configuration will be registered. pub fn new(config: &Config) -> Result { let http = reqwest::ClientBuilder::new() .redirect(reqwest::redirect::Policy::none()) .build() .expect("HTTP client should build"); - let github_client: BasicClient = - BasicClient::new(oauth2::ClientId::new(config.github_client_id.clone())) - .set_client_secret(oauth2::ClientSecret::new(config.github_client_secret.clone())) - .set_auth_uri(oauth2::AuthUrl::new("https://github.com/login/oauth/authorize".to_string())?) - .set_token_uri(oauth2::TokenUrl::new( - "https://github.com/login/oauth/access_token".to_string(), - )?) - .set_redirect_uri( - oauth2::RedirectUrl::new(format!("{}/auth/github/callback", config.public_base_url)) - .expect("Invalid redirect URI"), - ); - let mut providers: HashMap<&'static str, Arc> = HashMap::new(); - providers.insert("github", github::GitHubProvider::new(github_client, http.clone())); - // Discord OAuth client - let discord_client: BasicClient = - BasicClient::new(oauth2::ClientId::new(config.discord_client_id.clone())) - .set_client_secret(oauth2::ClientSecret::new(config.discord_client_secret.clone())) - .set_auth_uri(oauth2::AuthUrl::new("https://discord.com/api/oauth2/authorize".to_string())?) - .set_token_uri(oauth2::TokenUrl::new("https://discord.com/api/oauth2/token".to_string())?) - .set_redirect_uri( - oauth2::RedirectUrl::new(format!("{}/auth/discord/callback", config.public_base_url)) - .expect("Invalid redirect URI"), - ); - providers.insert("discord", discord::DiscordProvider::new(discord_client, http)); + // Register GitHub provider if configured + if let Some(github_config) = &config.github { + let github_client: BasicClient = + BasicClient::new(oauth2::ClientId::new(github_config.client_id.clone())) + .set_client_secret(oauth2::ClientSecret::new(github_config.client_secret.clone())) + .set_auth_uri(oauth2::AuthUrl::new("https://github.com/login/oauth/authorize".to_string())?) + .set_token_uri(oauth2::TokenUrl::new( + "https://github.com/login/oauth/access_token".to_string(), + )?) + .set_redirect_uri( + oauth2::RedirectUrl::new(format!("{}/auth/github/callback", config.public_base_url)) + .expect("Invalid redirect URI"), + ); + + providers.insert("github", github::GitHubProvider::new(github_client, http.clone())); + tracing::info!("GitHub OAuth provider registered"); + } + + // Register Discord provider if configured + if let Some(discord_config) = &config.discord { + let discord_client: BasicClient = + BasicClient::new(oauth2::ClientId::new(discord_config.client_id.clone())) + .set_client_secret(oauth2::ClientSecret::new(discord_config.client_secret.clone())) + .set_auth_uri(oauth2::AuthUrl::new("https://discord.com/api/oauth2/authorize".to_string())?) + .set_token_uri(oauth2::TokenUrl::new("https://discord.com/api/oauth2/token".to_string())?) + .set_redirect_uri( + oauth2::RedirectUrl::new(format!("{}/auth/discord/callback", config.public_base_url)) + .expect("Invalid redirect URI"), + ); + + providers.insert("discord", discord::DiscordProvider::new(discord_client, http)); + tracing::info!("Discord OAuth provider registered"); + } + + if providers.is_empty() { + tracing::warn!("No OAuth providers configured - authentication will be unavailable"); + } Ok(Self { providers }) } @@ -64,4 +79,9 @@ impl AuthRegistry { pub fn values(&self) -> impl Iterator> { self.providers.values() } + + /// Get the number of registered providers + pub fn len(&self) -> usize { + self.providers.len() + } } diff --git a/pacman-server/src/config.rs b/pacman-server/src/config.rs index 14db6be..6eef410 100644 --- a/pacman-server/src/config.rs +++ b/pacman-server/src/config.rs @@ -1,36 +1,184 @@ use figment::{providers::Env, value::UncasedStr, Figment}; use serde::{Deserialize, Deserializer}; +use std::env; -#[derive(Debug, Clone, Deserialize)] -pub struct Config { - // Database URL - pub database_url: String, - // Discord Credentials - #[serde(deserialize_with = "deserialize_string_from_any")] - pub discord_client_id: String, - pub discord_client_secret: String, - // GitHub Credentials - #[serde(deserialize_with = "deserialize_string_from_any")] - pub github_client_id: String, - pub github_client_secret: String, - // S3 Credentials - pub s3_access_key: String, - pub s3_secret_access_key: String, - pub s3_bucket_name: String, - pub s3_public_base_url: String, - // Server Details - #[serde(default = "default_port")] - pub port: u16, - #[serde(default = "default_host")] - pub host: std::net::IpAddr, - #[serde(default = "default_shutdown_timeout")] - pub shutdown_timeout_seconds: u32, - // Public base URL used for OAuth redirect URIs +/// Database configuration +#[derive(Debug, Clone)] +pub struct DatabaseConfig { + pub url: String, +} + +/// Discord OAuth configuration +#[derive(Debug, Clone)] +pub struct DiscordConfig { + pub client_id: String, + pub client_secret: String, +} + +/// GitHub OAuth configuration +#[derive(Debug, Clone)] +pub struct GithubConfig { + pub client_id: String, + pub client_secret: String, +} + +/// S3 storage configuration +#[derive(Debug, Clone)] +pub struct S3Config { + pub access_key: String, + pub secret_access_key: String, + pub bucket_name: String, pub public_base_url: String, - // JWT +} + +/// Main application configuration +#[derive(Debug, Clone, Deserialize)] +#[serde(from = "RawConfig")] +pub struct Config { + /// Database configuration - if None, uses SQLite in-memory + pub database: Option, + /// Discord OAuth - if None, Discord auth is disabled + pub discord: Option, + /// GitHub OAuth - if None, GitHub auth is disabled + pub github: Option, + /// S3 storage - if None, image storage is disabled + pub s3: Option, + /// Server port + pub port: u16, + /// Server host address + pub host: std::net::IpAddr, + /// Graceful shutdown timeout in seconds + pub shutdown_timeout_seconds: u32, + /// Public base URL for OAuth redirects + pub public_base_url: String, + /// JWT secret for session tokens pub jwt_secret: String, } +/// Raw configuration loaded directly from environment variables +/// This is an intermediate representation that gets validated and converted to Config +#[derive(Debug, Deserialize)] +struct RawConfig { + // Database + database_url: Option, + + // Discord OAuth + #[serde(default, deserialize_with = "deserialize_optional_string_from_any")] + discord_client_id: Option, + discord_client_secret: Option, + + // GitHub OAuth + #[serde(default, deserialize_with = "deserialize_optional_string_from_any")] + github_client_id: Option, + github_client_secret: Option, + + // S3 + s3_access_key: Option, + s3_secret_access_key: Option, + s3_bucket_name: Option, + s3_public_base_url: Option, + + // Server + #[serde(default = "default_port")] + port: u16, + #[serde(default = "default_host")] + host: std::net::IpAddr, + #[serde(default = "default_shutdown_timeout")] + shutdown_timeout_seconds: u32, + + // Required + public_base_url: String, + jwt_secret: String, +} + +impl From for Config { + fn from(raw: RawConfig) -> Self { + // Validate database config + let database = raw.database_url.map(|url| DatabaseConfig { url }); + + // Validate Discord config - if any field is set, all must be set + let discord = validate_feature_group( + "Discord", + &[ + ("DISCORD_CLIENT_ID", raw.discord_client_id.as_ref()), + ("DISCORD_CLIENT_SECRET", raw.discord_client_secret.as_ref()), + ], + ) + .map(|_| DiscordConfig { + client_id: raw.discord_client_id.unwrap(), + client_secret: raw.discord_client_secret.unwrap(), + }); + + // Validate GitHub config - if any field is set, all must be set + let github = validate_feature_group( + "GitHub", + &[ + ("GITHUB_CLIENT_ID", raw.github_client_id.as_ref()), + ("GITHUB_CLIENT_SECRET", raw.github_client_secret.as_ref()), + ], + ) + .map(|_| GithubConfig { + client_id: raw.github_client_id.unwrap(), + client_secret: raw.github_client_secret.unwrap(), + }); + + // Validate S3 config - if any field is set, all must be set + let s3 = validate_feature_group( + "S3", + &[ + ("S3_ACCESS_KEY", raw.s3_access_key.as_ref()), + ("S3_SECRET_ACCESS_KEY", raw.s3_secret_access_key.as_ref()), + ("S3_BUCKET_NAME", raw.s3_bucket_name.as_ref()), + ("S3_PUBLIC_BASE_URL", raw.s3_public_base_url.as_ref()), + ], + ) + .map(|_| S3Config { + access_key: raw.s3_access_key.unwrap(), + secret_access_key: raw.s3_secret_access_key.unwrap(), + bucket_name: raw.s3_bucket_name.unwrap(), + public_base_url: raw.s3_public_base_url.unwrap(), + }); + + Config { + database, + discord, + github, + s3, + port: raw.port, + host: raw.host, + shutdown_timeout_seconds: raw.shutdown_timeout_seconds, + public_base_url: raw.public_base_url, + jwt_secret: raw.jwt_secret, + } + } +} + +/// Validates a feature group - returns Some(()) if all fields are set, None if all are unset, +/// or panics if only some fields are set (partial configuration). +fn validate_feature_group(feature_name: &str, fields: &[(&str, Option<&String>)]) -> Option<()> { + let set_fields: Vec<&str> = fields.iter().filter(|(_, v)| v.is_some()).map(|(name, _)| *name).collect(); + + let unset_fields: Vec<&str> = fields.iter().filter(|(_, v)| v.is_none()).map(|(name, _)| *name).collect(); + + if set_fields.is_empty() { + // All unset - feature disabled + None + } else if unset_fields.is_empty() { + // All set - feature enabled + Some(()) + } else { + // Partial configuration - this is an error + panic!( + "{} configuration is incomplete. Set fields: [{}]. Missing fields: [{}]. \ + Either set all {} environment variables or none of them.", + feature_name, + set_fields.join(", "), + unset_fields.join(", "), + feature_name + ); + } +} + // Standard User-Agent: name/version (+site) pub const USER_AGENT: &str = concat!( env!("CARGO_PKG_NAME"), @@ -51,17 +199,18 @@ fn default_shutdown_timeout() -> u32 { 5 } -fn deserialize_string_from_any<'de, D>(deserializer: D) -> Result +fn deserialize_optional_string_from_any<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { use serde_json::Value; - let value = Value::deserialize(deserializer)?; + let value: Option = Option::deserialize(deserializer)?; match value { - Value::String(s) => Ok(s), - Value::Number(n) => Ok(n.to_string()), - _ => Err(serde::de::Error::custom("Expected string or number")), + Some(Value::String(s)) => Ok(Some(s)), + Some(Value::Number(n)) => Ok(Some(n.to_string())), + Some(Value::Null) | None => Ok(None), + _ => Err(serde::de::Error::custom("Expected string, number, or null")), } } @@ -77,3 +226,55 @@ pub fn load_config() -> Config { .extract() .expect("Failed to load config") } + +/// Create a minimal config for testing with specific overrides +/// This is useful for tests that don't need full configuration +#[cfg(test)] +pub fn test_config() -> Config { + Config { + database: None, + discord: None, + github: None, + s3: None, + port: 0, + 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(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_feature_group_all_set() { + let a = Some("value_a".to_string()); + let b = Some("value_b".to_string()); + let result = validate_feature_group("Test", &[("A", a.as_ref()), ("B", b.as_ref())]); + assert!(result.is_some()); + } + + #[test] + fn test_validate_feature_group_none_set() { + let result = validate_feature_group("Test", &[("A", None), ("B", None)]); + assert!(result.is_none()); + } + + #[test] + #[should_panic(expected = "Test configuration is incomplete")] + fn test_validate_feature_group_partial_panics() { + let a = Some("value_a".to_string()); + validate_feature_group("Test", &[("A", a.as_ref()), ("B", None)]); + } + + #[test] + fn test_minimal_config() { + let config = test_config(); + assert!(config.database.is_none()); + assert!(config.discord.is_none()); + assert!(config.github.is_none()); + assert!(config.s3.is_none()); + } +} diff --git a/pacman-server/src/data/pool.rs b/pacman-server/src/data/pool.rs index c1d96c7..0c1d1b4 100644 --- a/pacman-server/src/data/pool.rs +++ b/pacman-server/src/data/pool.rs @@ -3,6 +3,12 @@ use tracing::{info, warn}; pub type PgPool = Pool; +/// Create a PostgreSQL database pool. +/// +/// - `immediate`: If true, establishes connection immediately (panics on failure). +/// If false, uses lazy connection (for tests or when database may not be needed). +/// - `database_url`: The database connection URL. +/// - `max_connections`: Maximum number of connections in the pool. pub async fn create_pool(immediate: bool, database_url: &str, max_connections: u32) -> PgPool { info!(immediate, "Connecting to PostgreSQL"); @@ -19,3 +25,14 @@ pub async fn create_pool(immediate: bool, database_url: &str, max_connections: u .expect("Failed to create lazy database pool") } } + +/// Create a dummy pool that will fail on any actual database operation. +/// Used when database is not configured but the app still needs to start. +pub fn create_dummy_pool() -> PgPool { + // This creates a pool with an invalid URL that will fail on actual use + // The pool itself can be created (lazy), but any operation will fail + PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://invalid:invalid@localhost:5432/invalid") + .expect("Failed to create dummy pool") +} diff --git a/pacman-server/src/data/user.rs b/pacman-server/src/data/user.rs index 479cd9a..22aaf22 100644 --- a/pacman-server/src/data/user.rs +++ b/pacman-server/src/data/user.rs @@ -1,6 +1,8 @@ use serde::Serialize; use sqlx::FromRow; +use super::pool::PgPool; + #[derive(Debug, Clone, Serialize, FromRow)] pub struct User { pub id: i64, @@ -23,7 +25,7 @@ pub struct OAuthAccount { pub updated_at: chrono::DateTime, } -pub async fn find_user_by_email(pool: &sqlx::PgPool, email: &str) -> Result, sqlx::Error> { +pub async fn find_user_by_email(pool: &PgPool, email: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, User>( r#" SELECT id, email, created_at, updated_at @@ -37,7 +39,7 @@ pub async fn find_user_by_email(pool: &sqlx::PgPool, email: &str) -> Result) -> Result { +pub async fn create_user(pool: &PgPool, email: Option<&str>) -> Result { sqlx::query_as::<_, User>( r#" INSERT INTO users (email) @@ -81,7 +83,7 @@ pub async fn create_user(pool: &sqlx::PgPool, email: Option<&str>) -> Result Result, sqlx::Error> { @@ -110,7 +112,7 @@ pub struct ProviderPublic { pub avatar_url: Option, } -pub async fn list_user_providers(pool: &sqlx::PgPool, user_id: i64) -> Result, sqlx::Error> { +pub async fn list_user_providers(pool: &PgPool, user_id: i64) -> Result, sqlx::Error> { let recs = sqlx::query_as::<_, ProviderPublic>( r#" SELECT provider, provider_user_id, email, username, display_name, avatar_url diff --git a/pacman-server/src/image.rs b/pacman-server/src/image.rs index 9157463..68adb83 100644 --- a/pacman-server/src/image.rs +++ b/pacman-server/src/image.rs @@ -5,10 +5,10 @@ use s3::Bucket; use sha2::Digest; use tracing::trace; -use crate::config::Config; +use crate::config::S3Config; /// Minimal S3-backed image storage. This keeps things intentionally simple for now: -/// - construct from existing `Config` +/// - construct from existing `S3Config` /// - upload raw bytes under a key /// - upload a local file by path (reads whole file into memory) /// - generate a simple presigned GET URL @@ -22,14 +22,14 @@ pub struct ImageStorage { } impl ImageStorage { - /// Create a new storage for a specific `bucket_name` using settings from `Config`. + /// Create a new storage for a specific `bucket_name` using the provided S3 config. /// /// This uses a custom region + endpoint so it works across AWS S3 and compatible services /// such as Cloudflare R2 and MinIO. - pub fn new(config: &Config, bucket_name: impl Into) -> Result> { + pub fn new(config: &S3Config, bucket_name: impl Into) -> Result> { let credentials = s3::creds::Credentials::new( - Some(&config.s3_access_key), - Some(&config.s3_secret_access_key), + Some(&config.access_key), + Some(&config.secret_access_key), None, // security token None, // session token None, // profile @@ -46,7 +46,7 @@ impl ImageStorage { Ok(Self { bucket: Arc::new(bucket), - public_base_url: config.s3_public_base_url.clone(), + public_base_url: config.public_base_url.clone(), }) } @@ -172,9 +172,9 @@ pub struct AvatarUrls { } impl ImageStorage { - /// Create a new storage using the default bucket from `Config`. - pub fn from_config(config: &Config) -> Result> { - Self::new(config, &config.s3_bucket_name) + /// Create a new storage using the bucket from `S3Config`. + pub fn from_config(config: &S3Config) -> Result> { + Self::new(config, &config.bucket_name) } } diff --git a/pacman-server/src/main.rs b/pacman-server/src/main.rs index 3b809cc..7d7955c 100644 --- a/pacman-server/src/main.rs +++ b/pacman-server/src/main.rs @@ -5,6 +5,7 @@ use crate::{ app::{create_router, AppState}, auth::AuthRegistry, config::Config, + data::pool::{create_dummy_pool, create_pool}, }; use std::sync::Arc; use std::time::Instant; @@ -48,24 +49,52 @@ async fn main() { logging::setup_logging(); trace!(host = %config.host, port = config.port, shutdown_timeout_seconds = config.shutdown_timeout_seconds, "Loaded server configuration"); + // Log configuration status + info!( + database = config.database.is_some(), + discord = config.discord.is_some(), + github = config.github.is_some(), + s3 = config.s3.is_some(), + "Feature configuration" + ); + let addr = std::net::SocketAddr::new(config.host, config.port); let shutdown_timeout = std::time::Duration::from_secs(config.shutdown_timeout_seconds as u64); - let auth = AuthRegistry::new(&config).expect("auth initializer"); - let db = data::pool::create_pool(true, &config.database_url, 10).await; - // Run database migrations at startup - if let Err(e) = sqlx::migrate!("./migrations").run(&db).await { - panic!("failed to run database migrations: {}", e); - } + // Initialize auth registry (only enabled providers will be registered) + let auth = AuthRegistry::new(&config).expect("auth initializer"); + + // Initialize database - either connect to configured database or create a dummy pool + let db = if let Some(ref db_config) = config.database { + info!("Connecting to configured database"); + let pool = create_pool(true, &db_config.url, 10).await; + + // Run migrations + info!("Running database migrations"); + if let Err(e) = sqlx::migrate!("./migrations").run(&pool).await { + panic!("failed to run database migrations: {}", e); + } + + pool + } else { + info!("No database configured, creating dummy pool (database-dependent features will be unavailable)"); + create_dummy_pool() + }; // Create the shutdown notification before creating AppState let notify = Arc::new(Notify::new()); let app_state = AppState::new(config, auth, db, notify.clone()).await; { - // migrations succeeded + // Set health status based on configuration let mut h = app_state.health.write().await; - h.set_migrations(true); + if app_state.database_configured { + // Database was configured - migrations ran successfully + h.set_migrations(true); + h.set_database(true); + } + // If database is not configured, Health::ok() returns true by default + // because database_enabled is false } let app = create_router(app_state); diff --git a/pacman-server/src/routes.rs b/pacman-server/src/routes.rs index 4c4df66..9e66604 100644 --- a/pacman-server/src/routes.rs +++ b/pacman-server/src/routes.rs @@ -53,6 +53,17 @@ pub async fn oauth_callback_handler( Query(params): Query, cookie: CookieManager, ) -> axum::response::Response { + // Check if database is configured - required for OAuth callback to work + if !app_state.database_configured { + warn!("OAuth callback attempted but database is not configured"); + return ErrorResponse::with_status( + StatusCode::SERVICE_UNAVAILABLE, + "database_not_configured", + Some("Database is not configured. User authentication requires a database.".into()), + ) + .into_response(); + } + // Validate provider let Some(prov) = app_state.auth.get(&provider) else { warn!(%provider, "Unknown OAuth provider"); @@ -146,33 +157,35 @@ pub async fn oauth_callback_handler( session::set_session_cookie(&cookie, &session_token); info!(%provider, "Signed in successfully"); - // Process avatar asynchronously (don't block the response) - if let Some(avatar_url) = user.avatar_url.as_deref() { - let image_storage = app_state.image_storage.clone(); - let user_public_id = user.id.clone(); - let avatar_url = avatar_url.to_string(); - debug!(%user_public_id, %avatar_url, "Processing avatar"); + // Process avatar asynchronously (don't block the response) - only if image storage is configured + if let Some(image_storage) = &app_state.image_storage { + if let Some(avatar_url) = user.avatar_url.as_deref() { + let image_storage = image_storage.clone(); + let user_public_id = user.id.clone(); + let avatar_url = avatar_url.to_string(); + debug!(%user_public_id, %avatar_url, "Processing avatar"); - tokio::spawn(async move { - match image_storage.process_avatar(&user_public_id, &avatar_url).await { - Ok(avatar_urls) => { - info!( - user_id = %user_public_id, - original_url = %avatar_urls.original_url, - mini_url = %avatar_urls.mini_url, - "Avatar processed successfully" - ); + tokio::spawn(async move { + match image_storage.process_avatar(&user_public_id, &avatar_url).await { + Ok(avatar_urls) => { + info!( + user_id = %user_public_id, + original_url = %avatar_urls.original_url, + mini_url = %avatar_urls.mini_url, + "Avatar processed successfully" + ); + } + Err(e) => { + warn!( + user_id = %user_public_id, + avatar_url = %avatar_url, + error = %e, + "Failed to process avatar" + ); + } } - Err(e) => { - warn!( - user_id = %user_public_id, - avatar_url = %avatar_url, - error = %e, - "Failed to process avatar" - ); - } - } - }); + }); + } } (StatusCode::FOUND, Redirect::to("/api/profile")).into_response() @@ -182,6 +195,16 @@ pub async fn oauth_callback_handler( /// /// Requires the `session` cookie to be present. pub async fn profile_handler(State(app_state): State, cookie: CookieManager) -> axum::response::Response { + // Check if database is configured + if !app_state.database_configured { + return ErrorResponse::with_status( + StatusCode::SERVICE_UNAVAILABLE, + "database_not_configured", + Some("Database is not configured. Profile lookup requires a database.".into()), + ) + .into_response(); + } + let Some(token_str) = session::get_session_token(&cookie) else { debug!("Missing session cookie"); return ErrorResponse::unauthorized("missing session cookie").into_response(); @@ -287,8 +310,17 @@ pub async fn health_handler( app_state.check_health().await; } - let ok = app_state.health.read().await.ok(); + let health = app_state.health.read().await; + let ok = health.ok(); let status = if ok { StatusCode::OK } else { StatusCode::SERVICE_UNAVAILABLE }; - let body = serde_json::json!({ "ok": ok }); + + // Include more details in the health response + let body = serde_json::json!({ + "ok": ok, + "database_configured": app_state.database_configured, + "auth_providers": app_state.auth.len(), + "image_storage_enabled": app_state.image_storage.is_some(), + }); + (status, axum::Json(body)).into_response() } diff --git a/pacman-server/tests/common/mod.rs b/pacman-server/tests/common/mod.rs index cacc70f..176dc2b 100644 --- a/pacman-server/tests/common/mod.rs +++ b/pacman-server/tests/common/mod.rs @@ -3,7 +3,8 @@ use bon::builder; use pacman_server::{ app::{create_router, AppState}, auth::AuthRegistry, - config::Config, + config::{Config, DatabaseConfig, DiscordConfig, GithubConfig}, + data::pool::{create_dummy_pool, create_pool}, }; use std::sync::{Arc, Once}; use testcontainers::{ @@ -23,12 +24,24 @@ pub struct TestContext { pub config: Config, pub server: TestServer, pub app_state: AppState, - // Optional database + // Optional database container (only for Postgres tests) pub container: Option>, } #[builder] -pub async fn test_context(#[builder(default = false)] use_database: bool, auth_registry: Option) -> TestContext { +pub async fn test_context( + /// Whether to use a real PostgreSQL database via testcontainers (default: false) + #[builder(default = false)] + use_database: bool, + /// Optional custom AuthRegistry (otherwise built from config) + auth_registry: Option, + /// Include Discord OAuth config (default: true for backward compatibility) + #[builder(default = true)] + with_discord: bool, + /// Include GitHub OAuth config (default: true for backward compatibility) + #[builder(default = true)] + with_github: bool, +) -> TestContext { CRYPTO_INIT.call_once(|| { rustls::crypto::ring::default_provider() .install_default() @@ -38,7 +51,8 @@ pub async fn test_context(#[builder(default = false)] use_database: bool, auth_r // Set up logging std::env::set_var("RUST_LOG", "debug,sqlx=info"); pacman_server::logging::setup_logging(); - let (database_url, container) = if use_database { + + let (database_config, container) = if use_database { let db = "testdb"; let user = "testuser"; let password = "testpass"; @@ -59,47 +73,59 @@ pub async fn test_context(#[builder(default = false)] use_database: bool, auth_r let port = container.get_host_port_ipv4(5432).await.unwrap(); tracing::debug!(host = %host, port = %port, duration = ?duration, "Test database ready"); - ( - Some(format!("postgresql://{user}:{password}@{host}:{port}/{db}?sslmode=disable")), - Some(container), - ) + let url = format!("postgresql://{user}:{password}@{host}:{port}/{db}?sslmode=disable"); + (Some(DatabaseConfig { url }), Some(container)) } else { (None, None) }; + // Build OAuth configs if requested + let discord = if with_discord { + Some(DiscordConfig { + client_id: "test_discord_client_id".to_string(), + client_secret: "test_discord_client_secret".to_string(), + }) + } else { + None + }; + + let github = if with_github { + Some(GithubConfig { + client_id: "test_github_client_id".to_string(), + client_secret: "test_github_client_secret".to_string(), + }) + } else { + None + }; + 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 + database: database_config, + discord, + github, + s3: None, // Tests don't need S3 + 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 db = if use_database { - let db = pacman_server::data::pool::create_pool(use_database, &database_url.unwrap(), 5).await; + // Create database pool + let db = if let Some(ref db_config) = config.database { + let pool = create_pool(false, &db_config.url, 5).await; - // Run migrations + // Run migrations for Postgres sqlx::migrate!("./migrations") - .run(&db) + .run(&pool) .instrument(debug_span!("running_migrations")) .await .expect("Failed to run database migrations"); debug!("Database migrations ran successfully"); - db + pool } 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 dummy pool for tests that don't need database + create_dummy_pool() }; // Create auth registry @@ -107,13 +133,15 @@ pub async fn test_context(#[builder(default = false)] use_database: bool, auth_r // Create app state let notify = Arc::new(Notify::new()); - let app_state = AppState::new_with_database(config.clone(), auth, db, notify, use_database).await; + let app_state = AppState::new_with_options(config.clone(), auth, db, notify, use_database).await; - // Set health status based on database usage + // Set health status { let mut health = app_state.health.write().await; - health.set_migrations(use_database); - health.set_database(use_database); + if use_database { + health.set_migrations(true); + health.set_database(true); + } } let router = create_router(app_state.clone()); diff --git a/pacman-server/tests/config_optional.rs b/pacman-server/tests/config_optional.rs new file mode 100644 index 0000000..6609a9f --- /dev/null +++ b/pacman-server/tests/config_optional.rs @@ -0,0 +1,208 @@ +//! Tests for optional configuration features +//! +//! These tests verify that: +//! 1. The server can start without database, Discord, GitHub, or S3 configured +//! 2. Partial configuration (e.g., only DISCORD_CLIENT_ID) fails with a clear error +//! 3. Routes behave correctly when features are disabled + +mod common; + +use axum::http::StatusCode; +use pretty_assertions::assert_eq; + +use crate::common::{test_context, TestContext}; + +/// Test that the server starts and responds to health checks without any OAuth providers +#[tokio::test] +async fn test_server_without_oauth_providers() { + let TestContext { server, app_state, .. } = test_context() + .with_discord(false) + .with_github(false) + .use_database(false) + .call() + .await; + + // Verify no providers registered + assert_eq!(app_state.auth.len(), 0); + + // Health check should work + let response = server.get("/api/health").await; + assert_eq!(response.status_code(), StatusCode::OK); + + // Providers endpoint should return empty list + let response = server.get("/api/auth/providers").await; + assert_eq!(response.status_code(), StatusCode::OK); + let body: Vec = response.json(); + assert!(body.is_empty()); +} + +/// Test that the server starts with only Discord configured +#[tokio::test] +async fn test_server_with_discord_only() { + let TestContext { server, app_state, .. } = test_context() + .with_discord(true) + .with_github(false) + .use_database(false) + .call() + .await; + + // Verify only Discord is registered + assert_eq!(app_state.auth.len(), 1); + assert!(app_state.auth.get("discord").is_some()); + assert!(app_state.auth.get("github").is_none()); + + // Providers endpoint should return only Discord + let response = server.get("/api/auth/providers").await; + assert_eq!(response.status_code(), StatusCode::OK); + let body: Vec = response.json(); + assert_eq!(body.len(), 1); + assert_eq!(body[0]["id"], "discord"); +} + +/// Test that the server starts with only GitHub configured +#[tokio::test] +async fn test_server_with_github_only() { + let TestContext { server, app_state, .. } = test_context() + .with_discord(false) + .with_github(true) + .use_database(false) + .call() + .await; + + // Verify only GitHub is registered + assert_eq!(app_state.auth.len(), 1); + assert!(app_state.auth.get("github").is_some()); + assert!(app_state.auth.get("discord").is_none()); + + // Providers endpoint should return only GitHub + let response = server.get("/api/auth/providers").await; + assert_eq!(response.status_code(), StatusCode::OK); + let body: Vec = response.json(); + assert_eq!(body.len(), 1); + assert_eq!(body[0]["id"], "github"); +} + +/// Test that the server starts without database configured +#[tokio::test] +async fn test_server_without_database() { + let TestContext { + server, + app_state, + config, + .. + } = test_context().use_database(false).call().await; + + // Verify database is not configured + assert!(config.database.is_none()); + assert!(!app_state.database_configured); + + // Health check should still work + let response = server.get("/api/health").await; + assert_eq!(response.status_code(), StatusCode::OK); + + let body: serde_json::Value = response.json(); + assert_eq!(body["ok"], true); + assert_eq!(body["database_configured"], false); +} + +/// Test that profile endpoint returns 503 when database is not configured +#[tokio::test] +async fn test_profile_without_database_returns_503() { + let TestContext { server, .. } = test_context().use_database(false).call().await; + + // Create a fake session cookie to get past the auth check + let response = server.get("/api/profile").await; + + // Should return 503 Service Unavailable because database is not configured + assert_eq!(response.status_code(), StatusCode::SERVICE_UNAVAILABLE); + + let body: serde_json::Value = response.json(); + assert_eq!(body["error"], "database_not_configured"); +} + +/// Test that OAuth callback returns 503 when database is not configured +#[tokio::test] +async fn test_oauth_callback_without_database_returns_503() { + let TestContext { server, .. } = test_context().with_github(true).use_database(false).call().await; + + // Try to complete OAuth flow - should fail because database is not configured + let response = server + .get("/api/auth/github/callback") + .add_query_param("code", "test_code") + .add_query_param("state", "test_state") + .await; + + // Should return 503 Service Unavailable because database is not configured + assert_eq!(response.status_code(), StatusCode::SERVICE_UNAVAILABLE); + + let body: serde_json::Value = response.json(); + assert_eq!(body["error"], "database_not_configured"); +} + +/// Test that unknown provider returns 400 +#[tokio::test] +async fn test_unknown_provider_returns_400() { + let TestContext { server, .. } = test_context().with_discord(true).use_database(false).call().await; + + // Try to access non-existent provider + let response = server.get("/api/auth/twitter").await; + assert_eq!(response.status_code(), StatusCode::BAD_REQUEST); + + let body: serde_json::Value = response.json(); + assert_eq!(body["error"], "invalid_provider"); +} + +/// Test that logout works without database +#[tokio::test] +async fn test_logout_without_database() { + let TestContext { server, .. } = test_context().use_database(false).call().await; + + // Logout should work even without database + let response = server.get("/api/logout").await; + + // Logout redirects to home + assert_eq!(response.status_code(), StatusCode::FOUND); +} + +/// Test basic routes work without database or OAuth +#[tokio::test] +async fn test_basic_routes_minimal_config() { + let TestContext { server, .. } = test_context() + .with_discord(false) + .with_github(false) + .use_database(false) + .call() + .await; + + // Root API endpoint + let response = server.get("/api/").await; + assert_eq!(response.status_code(), StatusCode::OK); + + // Health endpoint + let response = server.get("/api/health").await; + assert_eq!(response.status_code(), StatusCode::OK); + + // Providers endpoint (empty list) + let response = server.get("/api/auth/providers").await; + assert_eq!(response.status_code(), StatusCode::OK); +} + +/// Test health endpoint includes feature status +#[tokio::test] +async fn test_health_includes_feature_status() { + let TestContext { server, .. } = test_context() + .with_discord(true) + .with_github(false) + .use_database(false) + .call() + .await; + + let response = server.get("/api/health").await; + assert_eq!(response.status_code(), StatusCode::OK); + + let body: serde_json::Value = response.json(); + assert_eq!(body["ok"], true); + assert_eq!(body["database_configured"], false); + assert_eq!(body["auth_providers"], 1); // Only Discord + assert_eq!(body["image_storage_enabled"], false); // No S3 configured +} diff --git a/pacman/Justfile b/pacman/Justfile index a7bbbbd..b972be8 100644 --- a/pacman/Justfile +++ b/pacman/Justfile @@ -2,6 +2,10 @@ set shell := ["bash", "-c"] binary_extension := if os() == "windows" { ".exe" } else { "" } +# Run cargo-vcpkg build for SDL2 dependencies +vcpkg: + cargo vcpkg build + # Run the game, pass args (e.g., `just pacman::run -r` for release) run *args: cargo run -p pacman {{args}}