use serde::{Deserialize, Serialize}; use std::env; use base64::prelude::*; /// Configuration for CalDAV server connection and authentication. /// /// This struct holds all the necessary information to connect to a CalDAV server, /// including server URL, credentials, and optional collection paths. /// /// # Security Note /// /// The password field contains sensitive information and should be handled carefully. /// This struct implements `Debug` but in production, consider implementing a custom /// `Debug` that masks the password field. /// /// # Example /// /// ```rust /// use crate::config::CalDAVConfig; /// /// // Load configuration from environment variables /// let config = CalDAVConfig::from_env()?; /// /// // Use the configuration for HTTP requests /// let auth_header = format!("Basic {}", config.get_basic_auth()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CalDAVConfig { /// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/") pub server_url: String, /// Username for authentication with the CalDAV server pub username: String, /// Password for authentication with the CalDAV server /// /// **Security Note**: This contains sensitive information pub password: String, /// Optional path to the calendar collection on the server /// /// If not provided, the client will need to discover available calendars /// through CalDAV PROPFIND requests pub calendar_path: Option, /// Optional path to the tasks/todo collection on the server /// /// Some CalDAV servers store tasks separately from calendar events pub tasks_path: Option, } impl CalDAVConfig { /// Creates a new CalDAVConfig by loading values from environment variables. /// /// This method will attempt to load a `.env` file from the current directory /// and then read the following required environment variables: /// /// - `CALDAV_SERVER_URL`: The CalDAV server base URL /// - `CALDAV_USERNAME`: Username for authentication /// - `CALDAV_PASSWORD`: Password for authentication /// /// Optional environment variables: /// /// - `CALDAV_CALENDAR_PATH`: Path to calendar collection /// - `CALDAV_TASKS_PATH`: Path to tasks collection /// /// # Errors /// /// Returns `ConfigError::MissingVar` if any required environment variable /// is not set or cannot be read. /// /// # Example /// /// ```rust /// use crate::config::CalDAVConfig; /// /// match CalDAVConfig::from_env() { /// Ok(config) => { /// println!("Loaded config for server: {}", config.server_url); /// } /// Err(e) => { /// eprintln!("Failed to load config: {}", e); /// } /// } /// ``` pub fn from_env() -> Result { // Attempt to load .env file, but don't fail if it doesn't exist dotenvy::dotenv().ok(); let server_url = env::var("CALDAV_SERVER_URL") .map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?; let username = env::var("CALDAV_USERNAME") .map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?; let password = env::var("CALDAV_PASSWORD") .map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?; // Optional paths - it's fine if these are not set let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok(); let tasks_path = env::var("CALDAV_TASKS_PATH").ok(); Ok(CalDAVConfig { server_url, username, password, calendar_path, tasks_path, }) } /// Generates a Base64-encoded string for HTTP Basic Authentication. /// /// This method combines the username and password in the format /// `username:password` and encodes it using Base64, which is the /// standard format for the `Authorization: Basic` HTTP header. /// /// # Returns /// /// A Base64-encoded string that can be used directly in the /// `Authorization` header: `Authorization: Basic ` /// /// # Example /// /// ```rust /// use crate::config::CalDAVConfig; /// /// let config = CalDAVConfig { /// server_url: "https://example.com".to_string(), /// username: "user".to_string(), /// password: "pass".to_string(), /// calendar_path: None, /// tasks_path: None, /// }; /// /// let auth_value = config.get_basic_auth(); /// let auth_header = format!("Basic {}", auth_value); /// ``` pub fn get_basic_auth(&self) -> String { let credentials = format!("{}:{}", self.username, self.password); BASE64_STANDARD.encode(&credentials) } } /// Errors that can occur when loading or using CalDAV configuration. #[derive(Debug, thiserror::Error)] pub enum ConfigError { /// A required environment variable is missing or cannot be read. /// /// This error occurs when calling `CalDAVConfig::from_env()` and one of the /// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`, /// or `CALDAV_PASSWORD`) is not set. #[error("Missing environment variable: {0}")] MissingVar(String), /// The configuration contains invalid or malformed values. /// /// This could include malformed URLs, invalid authentication credentials, /// or other configuration issues that prevent proper CalDAV operation. #[error("Invalid configuration: {0}")] Invalid(String), } #[cfg(test)] mod tests { use super::*; #[test] fn test_basic_auth_encoding() { let config = CalDAVConfig { server_url: "https://example.com".to_string(), username: "testuser".to_string(), password: "testpass".to_string(), calendar_path: None, tasks_path: None, }; let auth = config.get_basic_auth(); let expected = BASE64_STANDARD.encode("testuser:testpass"); assert_eq!(auth, expected); } /// Integration test that authenticates with the actual Baikal CalDAV server /// /// This test requires a valid .env file with: /// - CALDAV_SERVER_URL /// - CALDAV_USERNAME /// - CALDAV_PASSWORD /// /// Run with: `cargo test test_baikal_auth` #[tokio::test] async fn test_baikal_auth() { // Load config from .env let config = CalDAVConfig::from_env() .expect("Failed to load CalDAV config from environment"); println!("Testing authentication to: {}", config.server_url); // Create HTTP client let client = reqwest::Client::new(); // Make a simple OPTIONS request to test authentication let response = client .request(reqwest::Method::OPTIONS, &config.server_url) .header("Authorization", format!("Basic {}", config.get_basic_auth())) .header("User-Agent", "calendar-app/0.1.0") .send() .await .expect("Failed to send request to CalDAV server"); println!("Response status: {}", response.status()); println!("Response headers: {:#?}", response.headers()); // Check if we got a successful response or at least not a 401 Unauthorized assert!( response.status().is_success() || response.status() != 401, "Authentication failed with status: {}. Check your credentials in .env", response.status() ); // For Baikal/CalDAV servers, we should see DAV headers assert!( response.headers().contains_key("dav") || response.headers().contains_key("DAV") || response.status().is_success(), "Server doesn't appear to be a CalDAV server - missing DAV headers" ); println!("✓ Authentication test passed!"); } /// Test making a PROPFIND request to discover calendars /// /// This test requires a valid .env file and makes an actual CalDAV PROPFIND request /// /// Run with: `cargo test test_propfind_calendars` #[tokio::test] async fn test_propfind_calendars() { let config = CalDAVConfig::from_env() .expect("Failed to load CalDAV config from environment"); let client = reqwest::Client::new(); // CalDAV PROPFIND request to discover calendars let propfind_body = r#" "#; let response = client .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url) .header("Authorization", format!("Basic {}", config.get_basic_auth())) .header("Content-Type", "application/xml") .header("Depth", "1") .header("User-Agent", "calendar-app/0.1.0") .body(propfind_body) .send() .await .expect("Failed to send PROPFIND request"); let status = response.status(); println!("PROPFIND Response status: {}", status); let body = response.text().await.expect("Failed to read response body"); println!("PROPFIND Response body: {}", body); // We should get a 207 Multi-Status for PROPFIND assert_eq!( status, reqwest::StatusCode::from_u16(207).unwrap(), "PROPFIND should return 207 Multi-Status" ); // The response should contain XML with calendar information assert!(body.contains("calendar"), "Response should contain calendar information"); println!("✓ PROPFIND calendars test passed!"); } }