Skip to main content

busbar_sf_auth/
credentials.rs

1//! Credentials trait and implementations.
2//!
3//! All credential types implement custom Debug to redact sensitive data.
4
5use crate::error::{Error, ErrorKind, Result};
6
7/// Trait for Salesforce credentials.
8pub trait Credentials: Send + Sync {
9    /// Get the Salesforce instance URL.
10    fn instance_url(&self) -> &str;
11
12    /// Get the access token.
13    fn access_token(&self) -> &str;
14
15    /// Get the API version (e.g., "62.0").
16    fn api_version(&self) -> &str;
17
18    /// Returns true if the credentials appear to be valid (non-empty).
19    fn is_valid(&self) -> bool {
20        !self.instance_url().is_empty() && !self.access_token().is_empty()
21    }
22}
23
24/// Standard Salesforce credentials implementation.
25///
26/// Sensitive fields (access_token, refresh_token) are redacted in Debug output
27/// to prevent accidental exposure in logs.
28#[derive(Clone)]
29pub struct SalesforceCredentials {
30    instance_url: String,
31    access_token: String,
32    api_version: String,
33    refresh_token: Option<String>,
34}
35
36impl std::fmt::Debug for SalesforceCredentials {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.debug_struct("SalesforceCredentials")
39            .field("instance_url", &self.instance_url)
40            .field("access_token", &"[REDACTED]")
41            .field("api_version", &self.api_version)
42            .field(
43                "refresh_token",
44                &self.refresh_token.as_ref().map(|_| "[REDACTED]"),
45            )
46            .finish()
47    }
48}
49
50impl SalesforceCredentials {
51    /// Create new credentials with the given values.
52    pub fn new(
53        instance_url: impl Into<String>,
54        access_token: impl Into<String>,
55        api_version: impl Into<String>,
56    ) -> Self {
57        Self {
58            instance_url: instance_url.into(),
59            access_token: access_token.into(),
60            api_version: api_version.into(),
61            refresh_token: None,
62        }
63    }
64
65    /// Create credentials with a refresh token.
66    pub fn with_refresh_token(mut self, refresh_token: impl Into<String>) -> Self {
67        self.refresh_token = Some(refresh_token.into());
68        self
69    }
70
71    /// Get the refresh token if available.
72    pub fn refresh_token(&self) -> Option<&str> {
73        self.refresh_token.as_deref()
74    }
75
76    /// Set a new access token (e.g., after refresh).
77    pub fn set_access_token(&mut self, token: impl Into<String>) {
78        self.access_token = token.into();
79    }
80
81    /// Revoke the current session by invalidating the access token or refresh token.
82    ///
83    /// This convenience method creates an `OAuthClient` and calls `revoke_token()` with
84    /// the current credentials. You can choose to revoke either the access token or the
85    /// refresh token (if available).
86    ///
87    /// # Token Type Behavior
88    ///
89    /// - **Revoking refresh token** (`revoke_refresh: true`): Invalidates the refresh token
90    ///   AND all associated access tokens. Use this for complete session termination.
91    ///   Requires a refresh token to be present in the credentials.
92    /// - **Revoking access token** (`revoke_refresh: false`): Invalidates only the current
93    ///   access token. The refresh token remains valid and can be used to obtain a new
94    ///   access token.
95    ///
96    /// # Arguments
97    ///
98    /// * `revoke_refresh` - If true, revokes the refresh token (and all access tokens).
99    ///   If false, revokes only the access token.
100    /// * `login_url` - The Salesforce login URL (e.g., <https://login.salesforce.com>
101    ///   for production or <https://test.salesforce.com> for sandbox).
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if:
106    /// - `revoke_refresh` is true but no refresh token is available
107    /// - The HTTP request to the revocation endpoint fails
108    /// - The Salesforce server returns an error response
109    ///
110    /// # Example
111    ///
112    /// ```no_run
113    /// # use busbar_sf_auth::{SalesforceCredentials, PRODUCTION_LOGIN_URL};
114    /// # async fn example() -> Result<(), busbar_sf_auth::Error> {
115    /// let creds = SalesforceCredentials::new(
116    ///     "https://na1.salesforce.com",
117    ///     "access_token",
118    ///     "62.0"
119    /// ).with_refresh_token("refresh_token");
120    ///
121    /// // Revoke the entire session (refresh token + all access tokens)
122    /// creds.revoke_session(true, PRODUCTION_LOGIN_URL).await?;
123    ///
124    /// // Or revoke just the access token
125    /// creds.revoke_session(false, PRODUCTION_LOGIN_URL).await?;
126    /// # Ok(())
127    /// # }
128    /// ```
129    pub async fn revoke_session(&self, revoke_refresh: bool, login_url: &str) -> Result<()> {
130        use crate::oauth::{OAuthClient, OAuthConfig};
131
132        // Determine which token to revoke
133        let token = if revoke_refresh {
134            self.refresh_token.as_ref().ok_or_else(|| {
135                Error::new(ErrorKind::InvalidInput(
136                    "Cannot revoke refresh token: no refresh token available".to_string(),
137                ))
138            })?
139        } else {
140            &self.access_token
141        };
142
143        // Create a minimal OAuth client just for revocation
144        let config = OAuthConfig::new("revoke_client");
145        let client = OAuthClient::new(config);
146
147        client.revoke_token(token, login_url).await
148    }
149
150    /// Load credentials from environment variables.
151    ///
152    /// Required environment variables:
153    /// - `SF_INSTANCE_URL` or `SALESFORCE_INSTANCE_URL`
154    /// - `SF_ACCESS_TOKEN` or `SALESFORCE_ACCESS_TOKEN`
155    ///
156    /// Optional:
157    /// - `SF_API_VERSION` or `SALESFORCE_API_VERSION` (default: "62.0")
158    /// - `SF_REFRESH_TOKEN` or `SALESFORCE_REFRESH_TOKEN`
159    pub fn from_env() -> Result<Self> {
160        let instance_url = std::env::var("SF_INSTANCE_URL")
161            .or_else(|_| std::env::var("SALESFORCE_INSTANCE_URL"))
162            .map_err(|_| Error::new(ErrorKind::EnvVar("SF_INSTANCE_URL".to_string())))?;
163
164        let access_token = std::env::var("SF_ACCESS_TOKEN")
165            .or_else(|_| std::env::var("SALESFORCE_ACCESS_TOKEN"))
166            .map_err(|_| Error::new(ErrorKind::EnvVar("SF_ACCESS_TOKEN".to_string())))?;
167
168        let api_version = std::env::var("SF_API_VERSION")
169            .or_else(|_| std::env::var("SALESFORCE_API_VERSION"))
170            .unwrap_or_else(|_| busbar_sf_client::DEFAULT_API_VERSION.to_string());
171
172        let refresh_token = std::env::var("SF_REFRESH_TOKEN")
173            .or_else(|_| std::env::var("SALESFORCE_REFRESH_TOKEN"))
174            .ok();
175
176        let mut creds = Self::new(instance_url, access_token, api_version);
177        if let Some(rt) = refresh_token {
178            creds = creds.with_refresh_token(rt);
179        }
180
181        Ok(creds)
182    }
183
184    /// Load credentials from SFDX CLI using an org alias or username.
185    ///
186    /// Requires the `sf` CLI to be installed and the org to be authenticated.
187    pub async fn from_sfdx_alias(alias_or_username: &str) -> Result<Self> {
188        use tokio::process::Command;
189
190        let output = Command::new("sf")
191            .args([
192                "org",
193                "display",
194                "--target-org",
195                alias_or_username,
196                "--json",
197            ])
198            .output()
199            .await
200            .map_err(|e| Error::new(ErrorKind::SfdxCli(format!("Failed to run sf CLI: {}", e))))?;
201
202        if !output.status.success() {
203            let stderr = String::from_utf8_lossy(&output.stderr);
204            return Err(Error::new(ErrorKind::SfdxCli(format!(
205                "sf org display failed: {}",
206                stderr
207            ))));
208        }
209
210        let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
211
212        let result = json.get("result").ok_or_else(|| {
213            Error::new(ErrorKind::SfdxCli("Missing 'result' in output".to_string()))
214        })?;
215
216        let instance_url = result
217            .get("instanceUrl")
218            .and_then(|v| v.as_str())
219            .ok_or_else(|| Error::new(ErrorKind::SfdxCli("Missing instanceUrl".to_string())))?;
220
221        let access_token = result
222            .get("accessToken")
223            .and_then(|v| v.as_str())
224            .ok_or_else(|| Error::new(ErrorKind::SfdxCli("Missing accessToken".to_string())))?;
225
226        let api_version = result
227            .get("apiVersion")
228            .and_then(|v| v.as_str())
229            .unwrap_or(busbar_sf_client::DEFAULT_API_VERSION);
230
231        Ok(Self::new(instance_url, access_token, api_version))
232    }
233
234    /// Load credentials from an SFDX auth URL.
235    ///
236    /// The SFDX auth URL format is:
237    /// - `force://<client_id>:<client_secret>:<refresh_token>@<instance_url>`
238    /// - `force://<client_id>::<refresh_token>@<instance_url>` (empty client_secret)
239    /// - `force://<client_id>:<client_secret>:<refresh_token>:<username>@<instance_url>` (with username)
240    ///
241    /// The client_secret can be empty (indicated by `::`) for the default Salesforce CLI
242    /// connected app. The username field is optional.
243    ///
244    /// This method will parse the auth URL and use the refresh token to obtain
245    /// an access token from Salesforce.
246    ///
247    /// # Example
248    /// ```no_run
249    /// # use busbar_sf_auth::SalesforceCredentials;
250    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
251    /// let auth_url = std::env::var("SF_AUTH_URL")?;
252    /// let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await?;
253    /// # Ok(())
254    /// # }
255    /// ```
256    pub async fn from_sfdx_auth_url(auth_url: &str) -> Result<Self> {
257        use crate::oauth::{OAuthClient, OAuthConfig};
258
259        // Parse the auth URL
260        // Format: force://<client_id>:<client_secret>:<refresh_token>@<instance_url>
261        // Or with username: force://<client_id>:<client_secret>:<refresh_token>:<username>@<instance_url>
262        if !auth_url.starts_with("force://") {
263            return Err(Error::new(ErrorKind::InvalidInput(
264                "Auth URL must start with force://".to_string(),
265            )));
266        }
267
268        let url = auth_url.strip_prefix("force://").unwrap();
269
270        // Split at @ to separate credentials from instance URL
271        let parts: Vec<&str> = url.splitn(2, '@').collect();
272        if parts.len() != 2 {
273            return Err(Error::new(ErrorKind::InvalidInput(
274                "Invalid auth URL format: missing @".to_string(),
275            )));
276        }
277
278        let credentials_part = parts[0];
279        let instance_url = parts[1];
280
281        // Split credentials into client_id:client_secret:refresh_token[:username]
282        // Username is optional, so we accept 3 or 4 parts
283        let cred_parts: Vec<&str> = credentials_part.splitn(4, ':').collect();
284        if cred_parts.len() < 3 {
285            return Err(Error::new(ErrorKind::InvalidInput(
286                "Invalid auth URL format: expected client_id:client_secret:refresh_token[:username]"
287                    .to_string(),
288            )));
289        }
290
291        let client_id = cred_parts[0];
292        let client_secret = if cred_parts[1].is_empty() {
293            None
294        } else {
295            Some(cred_parts[1].to_string())
296        };
297        // The refresh token is in the third position
298        let refresh_token = cred_parts[2];
299        // Username is optional (4th position if present, not used currently)
300
301        // Create OAuth client
302        let mut config = OAuthConfig::new(client_id);
303        if let Some(secret) = client_secret {
304            config = config.with_secret(secret);
305        }
306
307        let oauth_client = OAuthClient::new(config);
308
309        // Build token endpoint URL from instance URL
310        // For localhost/test servers, use the instance_url directly
311        // For Salesforce production/sandbox/scratch orgs, use the appropriate login URL
312        let token_url = if instance_url.contains("localhost") || instance_url.contains("127.0.0.1")
313        {
314            instance_url
315        } else if instance_url.contains("test.salesforce.com")
316            || instance_url.contains("sandbox")
317            || instance_url.contains(".scratch.")
318        {
319            "https://test.salesforce.com"
320        } else {
321            "https://login.salesforce.com"
322        };
323
324        // Use refresh token to get access token
325        let token_response = oauth_client
326            .refresh_token(refresh_token, token_url)
327            .await
328            .map_err(|e| {
329                // Enhance error message for expired refresh tokens
330                if matches!(&e.kind, ErrorKind::OAuth { error, .. } if error == "invalid_grant") {
331                    Error::new(ErrorKind::OAuth {
332                        error: "invalid_grant".to_string(),
333                        description: format!(
334                            "Refresh token expired or invalid. Generate a fresh SF_AUTH_URL using: \
335                            `sf org display --verbose --json | jq -r '.result.sfdxAuthUrl'`. \
336                            Original error: {}",
337                            e
338                        ),
339                    })
340                } else {
341                    e
342                }
343            })?;
344
345        // Create credentials from token response
346        let api_version = busbar_sf_client::DEFAULT_API_VERSION.to_string();
347        let mut creds = Self::new(
348            token_response.instance_url,
349            token_response.access_token,
350            api_version,
351        );
352        creds = creds.with_refresh_token(refresh_token);
353
354        Ok(creds)
355    }
356
357    /// Change the API version.
358    pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
359        self.api_version = version.into();
360        self
361    }
362
363    /// Get the base REST API URL for this org.
364    pub fn rest_api_url(&self) -> String {
365        format!(
366            "{}/services/data/v{}",
367            self.instance_url.trim_end_matches('/'),
368            self.api_version
369        )
370    }
371
372    /// Get the Tooling API URL for this org.
373    pub fn tooling_api_url(&self) -> String {
374        format!(
375            "{}/services/data/v{}/tooling",
376            self.instance_url.trim_end_matches('/'),
377            self.api_version
378        )
379    }
380
381    /// Get the Metadata API URL for this org.
382    pub fn metadata_api_url(&self) -> String {
383        format!(
384            "{}/services/Soap/m/{}",
385            self.instance_url.trim_end_matches('/'),
386            self.api_version
387        )
388    }
389
390    /// Get the Bulk API 2.0 URL for this org.
391    pub fn bulk_api_url(&self) -> String {
392        format!(
393            "{}/services/data/v{}/jobs",
394            self.instance_url.trim_end_matches('/'),
395            self.api_version
396        )
397    }
398}
399
400impl Credentials for SalesforceCredentials {
401    fn instance_url(&self) -> &str {
402        &self.instance_url
403    }
404
405    fn access_token(&self) -> &str {
406        &self.access_token
407    }
408
409    fn api_version(&self) -> &str {
410        &self.api_version
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn test_credentials_new() {
420        let creds =
421            SalesforceCredentials::new("https://test.salesforce.com", "access_token_123", "62.0");
422
423        assert_eq!(creds.instance_url(), "https://test.salesforce.com");
424        assert_eq!(creds.access_token(), "access_token_123");
425        assert_eq!(creds.api_version(), "62.0");
426        assert!(creds.is_valid());
427    }
428
429    #[test]
430    fn test_credentials_with_refresh_token() {
431        let creds =
432            SalesforceCredentials::new("https://test.salesforce.com", "access_token", "62.0")
433                .with_refresh_token("refresh_token_123");
434
435        assert_eq!(creds.refresh_token(), Some("refresh_token_123"));
436    }
437
438    #[test]
439    fn test_api_urls() {
440        let creds = SalesforceCredentials::new("https://na1.salesforce.com", "token", "62.0");
441
442        assert_eq!(
443            creds.rest_api_url(),
444            "https://na1.salesforce.com/services/data/v62.0"
445        );
446        assert_eq!(
447            creds.tooling_api_url(),
448            "https://na1.salesforce.com/services/data/v62.0/tooling"
449        );
450        assert_eq!(
451            creds.bulk_api_url(),
452            "https://na1.salesforce.com/services/data/v62.0/jobs"
453        );
454    }
455
456    #[test]
457    fn test_invalid_credentials() {
458        let creds = SalesforceCredentials::new("", "", "62.0");
459        assert!(!creds.is_valid());
460    }
461
462    #[test]
463    fn test_credentials_debug_redacts_tokens() {
464        let creds = SalesforceCredentials::new(
465            "https://test.salesforce.com",
466            "super_secret_access_token_12345",
467            "62.0",
468        )
469        .with_refresh_token("super_secret_refresh_token_67890");
470
471        let debug_output = format!("{:?}", creds);
472
473        // Should contain [REDACTED]
474        assert!(debug_output.contains("[REDACTED]"));
475
476        // Should NOT contain actual tokens
477        assert!(!debug_output.contains("super_secret_access_token_12345"));
478        assert!(!debug_output.contains("super_secret_refresh_token_67890"));
479
480        // Should still contain non-sensitive data
481        assert!(debug_output.contains("test.salesforce.com"));
482        assert!(debug_output.contains("62.0"));
483    }
484
485    #[test]
486    fn test_parse_auth_url_with_client_secret() {
487        // Test parsing with client_secret present
488        // Format: force://<client_id>:<client_secret>:<refresh_token>@<instance_url>
489        let auth_url = "force://client123:secret456:refresh789@https://test.salesforce.com";
490
491        // We can't test the full async function without mocking the OAuth server,
492        // but we can test the parsing logic by extracting it
493        let url = auth_url.strip_prefix("force://").unwrap();
494        let parts: Vec<&str> = url.splitn(2, '@').collect();
495        assert_eq!(parts.len(), 2);
496
497        let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
498        assert!(cred_parts.len() >= 3);
499        assert_eq!(cred_parts[0], "client123");
500        assert_eq!(cred_parts[1], "secret456");
501        assert_eq!(cred_parts[2], "refresh789");
502    }
503
504    #[test]
505    fn test_parse_auth_url_without_client_secret() {
506        // Test parsing with empty client_secret (default Salesforce CLI connected app)
507        // Format: force://<client_id>::<refresh_token>@<instance_url>
508        let auth_url = "force://client123::refresh789@https://test.salesforce.com";
509
510        let url = auth_url.strip_prefix("force://").unwrap();
511        let parts: Vec<&str> = url.splitn(2, '@').collect();
512        assert_eq!(parts.len(), 2);
513
514        let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
515        assert!(cred_parts.len() >= 3);
516        assert_eq!(cred_parts[0], "client123");
517        assert_eq!(cred_parts[1], ""); // Empty client_secret
518        assert_eq!(cred_parts[2], "refresh789");
519    }
520
521    #[test]
522    fn test_parse_auth_url_with_username() {
523        // Test parsing with username appended (optional 4th field)
524        // Format: force://<client_id>:<client_secret>:<refresh_token>:<username>@<instance_url>
525        // Note: username cannot contain @ since splitn(2, '@') splits on the first @,
526        // making it the delimiter between credentials and instance_url
527        let auth_url = "force://client123:secret456:refresh789:user@https://test.salesforce.com";
528
529        let url = auth_url.strip_prefix("force://").unwrap();
530        let parts: Vec<&str> = url.splitn(2, '@').collect();
531        assert_eq!(parts.len(), 2);
532
533        let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
534        assert_eq!(cred_parts.len(), 4);
535        assert_eq!(cred_parts[0], "client123");
536        assert_eq!(cred_parts[1], "secret456");
537        assert_eq!(cred_parts[2], "refresh789");
538        assert_eq!(cred_parts[3], "user");
539    }
540
541    #[test]
542    fn test_parse_auth_url_invalid_format() {
543        // Test invalid format with too few parts
544        let auth_url = "force://client123:secret456@https://test.salesforce.com";
545
546        let url = auth_url.strip_prefix("force://").unwrap();
547        let parts: Vec<&str> = url.splitn(2, '@').collect();
548        assert_eq!(parts.len(), 2);
549
550        let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
551        // Should have only 2 parts, which is less than the required 3
552        assert_eq!(cred_parts.len(), 2);
553        assert!(
554            cred_parts.len() < 3,
555            "Invalid format should have less than 3 parts"
556        );
557    }
558
559    #[tokio::test]
560    async fn test_from_sfdx_auth_url_with_client_secret() {
561        use wiremock::matchers::{method, path};
562        use wiremock::{Mock, MockServer, ResponseTemplate};
563
564        // Set up mock OAuth server
565        let mock_server = MockServer::start().await;
566
567        Mock::given(method("POST"))
568            .and(path("/services/oauth2/token"))
569            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
570                "access_token": "test_access_token",
571                "instance_url": "https://na1.salesforce.com",
572                "id": "https://login.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
573                "token_type": "Bearer",
574                "issued_at": "1234567890"
575            })))
576            .mount(&mock_server)
577            .await;
578
579        let auth_url = format!(
580            "force://client123:secret456:refresh789@{}",
581            mock_server.uri()
582        );
583
584        let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
585        assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
586
587        let creds = creds.unwrap();
588        assert_eq!(creds.instance_url(), "https://na1.salesforce.com");
589        assert_eq!(creds.access_token(), "test_access_token");
590        assert_eq!(creds.refresh_token(), Some("refresh789"));
591    }
592
593    #[tokio::test]
594    async fn test_from_sfdx_auth_url_without_client_secret() {
595        use wiremock::matchers::{method, path};
596        use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate};
597
598        // Custom matcher to verify client_secret is NOT in the request
599        struct NoClientSecretMatcher;
600        impl Match for NoClientSecretMatcher {
601            fn matches(&self, request: &Request) -> bool {
602                let body = String::from_utf8_lossy(&request.body);
603                body.contains("client_id=client123")
604                    && body.contains("refresh_token=refresh789")
605                    && !body.contains("client_secret")
606            }
607        }
608
609        // Set up mock OAuth server
610        let mock_server = MockServer::start().await;
611
612        // Verify that client_secret is NOT sent when empty
613        Mock::given(method("POST"))
614            .and(path("/services/oauth2/token"))
615            .and(NoClientSecretMatcher)
616            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
617                "access_token": "test_access_token_no_secret",
618                "instance_url": "https://na1.salesforce.com",
619                "id": "https://login.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
620                "token_type": "Bearer",
621                "issued_at": "1234567890"
622            })))
623            .mount(&mock_server)
624            .await;
625
626        // Auth URL with empty client_secret (double colon ::)
627        let auth_url = format!("force://client123::refresh789@{}", mock_server.uri());
628
629        let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
630        assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
631
632        let creds = creds.unwrap();
633        assert_eq!(creds.instance_url(), "https://na1.salesforce.com");
634        assert_eq!(creds.access_token(), "test_access_token_no_secret");
635        assert_eq!(creds.refresh_token(), Some("refresh789"));
636    }
637
638    #[tokio::test]
639    async fn test_from_sfdx_auth_url_sandbox() {
640        use wiremock::matchers::{method, path};
641        use wiremock::{Mock, MockServer, ResponseTemplate};
642
643        // Set up mock OAuth server - note we can't actually test the sandbox URL selection
644        // without mocking the actual Salesforce endpoint, but we can test that the parsing works
645        let mock_server = MockServer::start().await;
646
647        Mock::given(method("POST"))
648            .and(path("/services/oauth2/token"))
649            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
650                "access_token": "test_access_token_sandbox",
651                "instance_url": "https://test.salesforce.com",
652                "id": "https://test.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
653                "token_type": "Bearer",
654                "issued_at": "1234567890"
655            })))
656            .mount(&mock_server)
657            .await;
658
659        // Use localhost in the auth URL so it uses the mock server
660        // In production, sandbox URLs would route to test.salesforce.com
661        let auth_url = format!(
662            "force://client123:secret456:refresh789@{}",
663            mock_server.uri()
664        );
665
666        let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
667        assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
668
669        let creds = creds.unwrap();
670        assert_eq!(creds.instance_url(), "https://test.salesforce.com");
671        assert_eq!(creds.access_token(), "test_access_token_sandbox");
672    }
673
674    #[tokio::test]
675    async fn test_from_sfdx_auth_url_with_username() {
676        use wiremock::matchers::{method, path};
677        use wiremock::{Mock, MockServer, ResponseTemplate};
678
679        // Set up mock OAuth server
680        let mock_server = MockServer::start().await;
681
682        Mock::given(method("POST"))
683            .and(path("/services/oauth2/token"))
684            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
685                "access_token": "test_access_token_with_user",
686                "instance_url": "https://na1.salesforce.com",
687                "id": "https://login.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
688                "token_type": "Bearer",
689                "issued_at": "1234567890"
690            })))
691            .mount(&mock_server)
692            .await;
693
694        // Auth URL with username field
695        let auth_url = format!(
696            "force://client123:secret456:refresh789:username@{}",
697            mock_server.uri()
698        );
699
700        let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
701        assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
702
703        let creds = creds.unwrap();
704        assert_eq!(creds.instance_url(), "https://na1.salesforce.com");
705        assert_eq!(creds.access_token(), "test_access_token_with_user");
706    }
707
708    #[tokio::test]
709    async fn test_from_sfdx_auth_url_invalid_too_few_parts() {
710        // Auth URL with only 2 parts (missing refresh token)
711        let auth_url = "force://client123:secret456@https://test.salesforce.com";
712
713        let creds = SalesforceCredentials::from_sfdx_auth_url(auth_url).await;
714        assert!(creds.is_err());
715        let err = creds.unwrap_err();
716        assert!(err
717            .to_string()
718            .contains("expected client_id:client_secret:refresh_token"));
719    }
720
721    #[tokio::test]
722    async fn test_revoke_session_access_token() {
723        use wiremock::matchers::{body_string_contains, method, path};
724        use wiremock::{Mock, MockServer, ResponseTemplate};
725
726        let mock_server = MockServer::start().await;
727
728        // Mock the revoke endpoint
729        Mock::given(method("POST"))
730            .and(path("/services/oauth2/revoke"))
731            .and(body_string_contains("token=test_access_token"))
732            .respond_with(ResponseTemplate::new(200))
733            .mount(&mock_server)
734            .await;
735
736        let creds =
737            SalesforceCredentials::new("https://na1.salesforce.com", "test_access_token", "62.0");
738
739        let result = creds.revoke_session(false, &mock_server.uri()).await;
740        assert!(result.is_ok(), "Revoking access token should succeed");
741    }
742
743    #[tokio::test]
744    async fn test_revoke_session_refresh_token() {
745        use wiremock::matchers::{body_string_contains, method, path};
746        use wiremock::{Mock, MockServer, ResponseTemplate};
747
748        let mock_server = MockServer::start().await;
749
750        // Mock the revoke endpoint
751        Mock::given(method("POST"))
752            .and(path("/services/oauth2/revoke"))
753            .and(body_string_contains("token=test_refresh_token"))
754            .respond_with(ResponseTemplate::new(200))
755            .mount(&mock_server)
756            .await;
757
758        let creds =
759            SalesforceCredentials::new("https://na1.salesforce.com", "test_access_token", "62.0")
760                .with_refresh_token("test_refresh_token");
761
762        let result = creds.revoke_session(true, &mock_server.uri()).await;
763        assert!(
764            result.is_ok(),
765            "Revoking refresh token should succeed: {:?}",
766            result.err()
767        );
768    }
769
770    #[tokio::test]
771    async fn test_revoke_session_no_refresh_token() {
772        let creds =
773            SalesforceCredentials::new("https://na1.salesforce.com", "test_access_token", "62.0");
774
775        // Try to revoke refresh token when none exists
776        let result = creds
777            .revoke_session(true, "https://login.salesforce.com")
778            .await;
779
780        assert!(result.is_err(), "Should fail when no refresh token exists");
781        let err = result.unwrap_err();
782        assert!(
783            matches!(err.kind, ErrorKind::InvalidInput(_)),
784            "Should return InvalidInput error"
785        );
786    }
787}