diff --git a/src/credential.rs b/src/credential.rs index 9b2d9ef..9cb6b2b 100644 --- a/src/credential.rs +++ b/src/credential.rs @@ -65,15 +65,13 @@ //! The credential module integrates seamlessly with the Gmail API client: //! //! ```rust,no_run -//! use cull_gmail::{Credential, ClientConfig, GmailClient}; +//! use cull_gmail::{ClientConfig, GmailClient}; //! //! # async fn example() -> Result<(), Box> { -//! // Load credentials -//! let credential = Credential::load_json_file("client_secret.json"); -//! -//! // Create client configuration -//! let config = ClientConfig::builder() -//! .with_credential(credential) +//! // Create client configuration with credential file +//! let mut config_builder = ClientConfig::builder(); +//! let config = config_builder +//! .with_credential_file("client_secret.json") //! .build(); //! //! // Initialize Gmail client @@ -132,38 +130,38 @@ pub struct Installed { /// This is a public identifier that uniquely identifies the OAuth2 application. /// It typically ends with `.googleusercontent.com`. pub(crate) client_id: String, - + /// Google Cloud Platform project identifier. /// /// Optional field that identifies the GCP project associated with these credentials. /// Used for quota management and billing purposes. pub(crate) project_id: Option, - + /// OAuth2 authorization endpoint URL. /// /// The URL where users are redirected to authenticate and authorize the application. /// Typically `https://accounts.google.com/o/oauth2/auth` for Google services. pub(crate) auth_uri: String, - + /// OAuth2 token exchange endpoint URL. /// /// The URL used to exchange authorization codes for access tokens. /// Typically `https://oauth2.googleapis.com/token` for Google services. pub(crate) token_uri: String, - + /// URL for OAuth2 provider's X.509 certificate. /// /// Optional URL pointing to the public certificates used to verify JWT tokens /// from the OAuth2 provider. Used for token validation. pub(crate) auth_provider_x509_cert_url: Option, - + /// OAuth2 client secret. /// /// **SENSITIVE**: This is a confidential value that must be kept secure. /// It's used to authenticate the application to the OAuth2 provider. /// Never log or expose this value. pub(crate) client_secret: String, - + /// List of authorized redirect URIs. /// /// These URIs are pre-registered with the OAuth2 provider and define @@ -361,3 +359,335 @@ impl From for yup_oauth2::ApplicationSecret { out } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + /// Helper function to create a temporary credential file for testing + fn create_test_credential_file(temp_dir: &TempDir, filename: &str, content: &str) -> String { + let file_path = temp_dir.path().join(filename); + fs::write(&file_path, content).expect("Failed to write test file"); + file_path.to_string_lossy().to_string() + } + + /// Sample valid credential JSON for testing + fn sample_valid_credential() -> &'static str { + r#"{ + "installed": { + "client_id": "123456789-test.googleusercontent.com", + "project_id": "test-project", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "test-client-secret", + "redirect_uris": ["http://localhost"] + } +}"# + } + + /// Sample minimal valid credential JSON for testing + fn sample_minimal_credential() -> &'static str { + r#"{ + "installed": { + "client_id": "minimal-client-id", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "client_secret": "minimal-secret", + "redirect_uris": [] + } +}"# + } + + #[test] + fn test_installed_struct_serialization() { + let installed = Installed { + client_id: "test-client-id".to_string(), + project_id: Some("test-project".to_string()), + auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), + token_uri: "https://oauth2.googleapis.com/token".to_string(), + auth_provider_x509_cert_url: Some( + "https://www.googleapis.com/oauth2/v1/certs".to_string(), + ), + client_secret: "test-secret".to_string(), + redirect_uris: vec!["http://localhost".to_string()], + }; + + // Test serialization + let json = serde_json::to_string(&installed).expect("Should serialize"); + assert!(json.contains("test-client-id")); + assert!(json.contains("test-secret")); + + // Test deserialization + let deserialized: Installed = serde_json::from_str(&json).expect("Should deserialize"); + assert_eq!(deserialized.client_id, installed.client_id); + assert_eq!(deserialized.client_secret, installed.client_secret); + } + + #[test] + fn test_credential_struct_serialization() { + let installed = Installed { + client_id: "test-id".to_string(), + project_id: None, + auth_uri: "auth-uri".to_string(), + token_uri: "token-uri".to_string(), + auth_provider_x509_cert_url: None, + client_secret: "secret".to_string(), + redirect_uris: vec![], + }; + + let credential = Credential { + installed: Some(installed), + }; + + // Test serialization + let json = serde_json::to_string(&credential).expect("Should serialize"); + assert!(json.contains("test-id")); + + // Test deserialization + let deserialized: Credential = serde_json::from_str(&json).expect("Should deserialize"); + assert!(deserialized.installed.is_some()); + assert_eq!(deserialized.installed.unwrap().client_id, "test-id"); + } + + #[test] + fn test_credential_with_valid_json() { + let json = sample_valid_credential(); + let credential: Credential = serde_json::from_str(json).expect("Should parse valid JSON"); + + assert!(credential.installed.is_some()); + let installed = credential.installed.unwrap(); + assert_eq!(installed.client_id, "123456789-test.googleusercontent.com"); + assert_eq!(installed.client_secret, "test-client-secret"); + assert_eq!(installed.project_id, Some("test-project".to_string())); + assert_eq!( + installed.auth_uri, + "https://accounts.google.com/o/oauth2/auth" + ); + assert_eq!(installed.token_uri, "https://oauth2.googleapis.com/token"); + assert_eq!(installed.redirect_uris, vec!["http://localhost"]); + } + + #[test] + fn test_credential_with_minimal_json() { + let json = sample_minimal_credential(); + let credential: Credential = serde_json::from_str(json).expect("Should parse minimal JSON"); + + assert!(credential.installed.is_some()); + let installed = credential.installed.unwrap(); + assert_eq!(installed.client_id, "minimal-client-id"); + assert_eq!(installed.client_secret, "minimal-secret"); + assert_eq!(installed.project_id, None); + assert!(installed.redirect_uris.is_empty()); + } + + #[test] + fn test_credential_with_empty_installed() { + let json = r#"{"installed": null}"#; + let credential: Credential = + serde_json::from_str(json).expect("Should parse null installed"); + assert!(credential.installed.is_none()); + } + + #[test] + fn test_credential_with_missing_installed() { + let json = r#"{}"#; + let credential: Credential = serde_json::from_str(json).expect("Should parse empty object"); + assert!(credential.installed.is_none()); + } + + #[test] + fn test_invalid_json_parsing() { + let invalid_cases = [ + "", // Empty string + "{", // Incomplete JSON + "not json", // Not JSON at all + r#"{"installed": "wrong"}"#, // Wrong type for installed + r#"{"installed": {"client_id": "test", "missing_required": true}}"#, // Missing required fields + ]; + + for invalid_json in invalid_cases { + let result = serde_json::from_str::(invalid_json); + assert!(result.is_err(), "Should fail to parse: {}", invalid_json); + } + } + + #[test] + fn test_conversion_to_application_secret() { + let json = sample_valid_credential(); + let credential: Credential = serde_json::from_str(json).unwrap(); + let app_secret: yup_oauth2::ApplicationSecret = credential.into(); + + assert_eq!(app_secret.client_id, "123456789-test.googleusercontent.com"); + assert_eq!(app_secret.client_secret, "test-client-secret"); + assert_eq!(app_secret.project_id, Some("test-project".to_string())); + assert_eq!( + app_secret.auth_uri, + "https://accounts.google.com/o/oauth2/auth" + ); + assert_eq!(app_secret.token_uri, "https://oauth2.googleapis.com/token"); + assert_eq!(app_secret.redirect_uris, vec!["http://localhost"]); + assert_eq!( + app_secret.auth_provider_x509_cert_url, + Some("https://www.googleapis.com/oauth2/v1/certs".to_string()) + ); + } + + #[test] + fn test_conversion_with_minimal_credential() { + let json = sample_minimal_credential(); + let credential: Credential = serde_json::from_str(json).unwrap(); + let app_secret: yup_oauth2::ApplicationSecret = credential.into(); + + assert_eq!(app_secret.client_id, "minimal-client-id"); + assert_eq!(app_secret.client_secret, "minimal-secret"); + assert_eq!(app_secret.project_id, None); + assert!(app_secret.redirect_uris.is_empty()); + } + + #[test] + fn test_conversion_with_empty_credential() { + let credential = Credential { installed: None }; + let app_secret: yup_oauth2::ApplicationSecret = credential.into(); + + // Should create default ApplicationSecret + assert!(app_secret.client_id.is_empty()); + assert!(app_secret.client_secret.is_empty()); + assert_eq!(app_secret.project_id, None); + } + + #[test] + fn test_debug_formatting_does_not_expose_secrets() { + let installed = Installed { + client_id: "public-client-id".to_string(), + project_id: Some("public-project".to_string()), + auth_uri: "https://auth.example.com".to_string(), + token_uri: "https://token.example.com".to_string(), + auth_provider_x509_cert_url: None, + client_secret: "VERY_SECRET_VALUE".to_string(), + redirect_uris: vec!["http://localhost:8080".to_string()], + }; + + let credential = Credential { + installed: Some(installed), + }; + + let debug_str = format!("{:?}", credential); + + // Debug should show the structure but we can't easily test that secrets are hidden + // since the current Debug implementation doesn't hide secrets + // This test mainly ensures Debug works without panicking + assert!(debug_str.contains("Credential")); + } + + #[test] + fn test_round_trip_serialization() { + let json = sample_valid_credential(); + let credential: Credential = serde_json::from_str(json).unwrap(); + let serialized = serde_json::to_string(&credential).unwrap(); + let deserialized: Credential = serde_json::from_str(&serialized).unwrap(); + + // Compare the installed sections + match (credential.installed, deserialized.installed) { + (Some(orig), Some(deser)) => { + assert_eq!(orig.client_id, deser.client_id); + assert_eq!(orig.client_secret, deser.client_secret); + assert_eq!(orig.project_id, deser.project_id); + assert_eq!(orig.auth_uri, deser.auth_uri); + assert_eq!(orig.token_uri, deser.token_uri); + assert_eq!(orig.redirect_uris, deser.redirect_uris); + } + _ => panic!("Both should have installed sections"), + } + } + + #[test] + fn test_field_validation_edge_cases() { + // Test with empty strings + let json = r#"{ + "installed": { + "client_id": "", + "auth_uri": "", + "token_uri": "", + "client_secret": "", + "redirect_uris": [] + } +}"#; + + let credential: Credential = + serde_json::from_str(json).expect("Should parse empty strings"); + let app_secret: yup_oauth2::ApplicationSecret = credential.into(); + assert!(app_secret.client_id.is_empty()); + assert!(app_secret.client_secret.is_empty()); + } + + #[test] + fn test_unicode_and_special_characters() { + let json = r#"{ + "installed": { + "client_id": "unicode-ใƒ†ใ‚นใƒˆ-๐Ÿ”-client", + "auth_uri": "https://auth.example.com/oauth2", + "token_uri": "https://token.example.com/oauth2", + "client_secret": "secret-with-symbols-!@#$%^&*()", + "redirect_uris": ["http://localhost:8080/callback"] + } +}"#; + + let credential: Credential = serde_json::from_str(json).expect("Should handle Unicode"); + let installed = credential.installed.unwrap(); + assert_eq!(installed.client_id, "unicode-ใƒ†ใ‚นใƒˆ-๐Ÿ”-client"); + assert_eq!(installed.client_secret, "secret-with-symbols-!@#$%^&*()"); + } + + #[test] + fn test_large_redirect_uris_list() { + let mut redirect_uris = Vec::new(); + for i in 0..100 { + redirect_uris.push(format!("http://localhost:{}", 8000 + i)); + } + + let installed = Installed { + client_id: "test-client".to_string(), + project_id: None, + auth_uri: "https://auth.example.com".to_string(), + token_uri: "https://token.example.com".to_string(), + auth_provider_x509_cert_url: None, + client_secret: "test-secret".to_string(), + redirect_uris: redirect_uris.clone(), + }; + + let credential = Credential { + installed: Some(installed), + }; + + let app_secret: yup_oauth2::ApplicationSecret = credential.into(); + assert_eq!(app_secret.redirect_uris.len(), 100); + assert_eq!(app_secret.redirect_uris[0], "http://localhost:8000"); + assert_eq!(app_secret.redirect_uris[99], "http://localhost:8099"); + } + + // Note: We can't easily test the actual file loading functionality + // without mocking the home directory and file system, which would + // require more complex test setup. The current implementation uses + // `env::home_dir()` and direct file operations that would need + // more sophisticated mocking to test properly. + + #[test] + fn test_credential_clone_and_equality() { + let json = sample_minimal_credential(); + let credential1: Credential = serde_json::from_str(json).unwrap(); + + // Test that we can create another credential from the same JSON + let credential2: Credential = serde_json::from_str(json).unwrap(); + + // We can't test equality directly since Credential doesn't implement PartialEq + // but we can test that conversions produce equivalent results + let secret1: yup_oauth2::ApplicationSecret = credential1.into(); + let secret2: yup_oauth2::ApplicationSecret = credential2.into(); + + assert_eq!(secret1.client_id, secret2.client_id); + assert_eq!(secret1.client_secret, secret2.client_secret); + } +}