firebase_rs_sdk/installations/
api.rs

1use std::collections::HashMap;
2use std::sync::{Arc, LazyLock, Mutex};
3
4use base64::engine::general_purpose::URL_SAFE_NO_PAD;
5use base64::Engine as _;
6use rand::{thread_rng, RngCore};
7
8use crate::app;
9use crate::app::FirebaseApp;
10use crate::component::types::{
11    ComponentError, DynService, InstanceFactoryOptions, InstantiationMode,
12};
13use crate::component::{Component, ComponentType};
14use crate::installations::config::{extract_app_config, AppConfig};
15use crate::installations::constants::{
16    INSTALLATIONS_COMPONENT_NAME, INSTALLATIONS_INTERNAL_COMPONENT_NAME,
17};
18use crate::installations::error::{internal_error, InstallationsResult};
19use crate::installations::persistence::{
20    FilePersistence, InstallationsPersistence, PersistedAuthToken, PersistedInstallation,
21};
22use crate::installations::rest::{RegisteredInstallation, RestClient};
23use crate::installations::types::InstallationToken;
24
25#[derive(Clone, Debug)]
26pub struct Installations {
27    inner: Arc<InstallationsInner>,
28}
29
30struct InstallationsInner {
31    app: FirebaseApp,
32    config: AppConfig,
33    rest_client: RestClient,
34    persistence: Arc<dyn InstallationsPersistence>,
35    state: Mutex<Option<InstallationEntry>>,
36}
37
38impl std::fmt::Debug for InstallationsInner {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct("InstallationsInner")
41            .field("app", &self.app)
42            .field("config", &self.config)
43            .field("rest_client", &self.rest_client)
44            .finish()
45    }
46}
47
48#[derive(Clone, Debug)]
49struct InstallationEntry {
50    fid: String,
51    refresh_token: String,
52    auth_token: InstallationToken,
53}
54
55impl InstallationEntry {
56    fn from_registered(value: RegisteredInstallation) -> Self {
57        Self {
58            fid: value.fid,
59            refresh_token: value.refresh_token,
60            auth_token: value.auth_token,
61        }
62    }
63
64    fn from_persisted(value: PersistedInstallation) -> Self {
65        Self {
66            fid: value.fid,
67            refresh_token: value.refresh_token,
68            auth_token: value.auth_token.into_runtime(),
69        }
70    }
71
72    fn to_persisted(&self) -> InstallationsResult<PersistedInstallation> {
73        Ok(PersistedInstallation {
74            fid: self.fid.clone(),
75            refresh_token: self.refresh_token.clone(),
76            auth_token: PersistedAuthToken::from_runtime(&self.auth_token)?,
77        })
78    }
79}
80
81#[derive(Clone, Debug)]
82pub struct InstallationsInternal {
83    installations: Arc<Installations>,
84}
85
86impl InstallationsInternal {
87    pub fn get_id(&self) -> InstallationsResult<String> {
88        self.installations.get_id()
89    }
90
91    pub fn get_token(&self, force_refresh: bool) -> InstallationsResult<InstallationToken> {
92        self.installations.get_token(force_refresh)
93    }
94}
95
96static INSTALLATIONS_CACHE: LazyLock<Mutex<HashMap<String, Arc<Installations>>>> =
97    LazyLock::new(|| Mutex::new(HashMap::new()));
98
99impl Installations {
100    fn new(app: FirebaseApp) -> InstallationsResult<Self> {
101        let config = extract_app_config(&app)?;
102        let rest_client = RestClient::new()?;
103        let persistence: Arc<dyn InstallationsPersistence> = Arc::new(FilePersistence::default()?);
104        let initial_state = persistence
105            .read(app.name())?
106            .map(InstallationEntry::from_persisted);
107        Ok(Self {
108            inner: Arc::new(InstallationsInner {
109                app,
110                config,
111                rest_client,
112                persistence,
113                state: Mutex::new(initial_state),
114            }),
115        })
116    }
117
118    pub fn app(&self) -> &FirebaseApp {
119        &self.inner.app
120    }
121
122    pub fn get_id(&self) -> InstallationsResult<String> {
123        let entry = self.ensure_entry()?;
124        Ok(entry.fid)
125    }
126
127    pub fn get_token(&self, force_refresh: bool) -> InstallationsResult<InstallationToken> {
128        let entry = self.ensure_entry()?;
129        if !force_refresh && !entry.auth_token.is_expired() {
130            return Ok(entry.auth_token.clone());
131        }
132
133        let new_token = self.inner.rest_client.generate_auth_token(
134            &self.inner.config,
135            &entry.fid,
136            &entry.refresh_token,
137        )?;
138
139        {
140            let mut state = self.inner.state.lock().unwrap();
141            match state.as_mut() {
142                Some(stored) if stored.fid == entry.fid => stored.auth_token = new_token.clone(),
143                Some(stored) => {
144                    *stored = InstallationEntry {
145                        fid: entry.fid.clone(),
146                        refresh_token: entry.refresh_token.clone(),
147                        auth_token: new_token.clone(),
148                    };
149                }
150                None => {
151                    state.replace(InstallationEntry {
152                        fid: entry.fid.clone(),
153                        refresh_token: entry.refresh_token.clone(),
154                        auth_token: new_token.clone(),
155                    });
156                }
157            }
158        }
159
160        self.persist_current_state()?;
161
162        Ok(new_token)
163    }
164
165    fn ensure_entry(&self) -> InstallationsResult<InstallationEntry> {
166        if let Some(entry) = self.inner.state.lock().unwrap().clone() {
167            return Ok(entry);
168        }
169
170        let registered = self.register_remote_installation()?;
171        let mut state = self.inner.state.lock().unwrap();
172        if let Some(existing) = state.as_ref() {
173            return Ok(existing.clone());
174        }
175        state.replace(registered.clone());
176        drop(state);
177        self.persist_entry(&registered)?;
178        Ok(registered)
179    }
180
181    fn register_remote_installation(&self) -> InstallationsResult<InstallationEntry> {
182        let fid = generate_fid()?;
183        let registered = self
184            .inner
185            .rest_client
186            .register_installation(&self.inner.config, &fid)?;
187        Ok(InstallationEntry::from_registered(registered))
188    }
189
190    fn persist_entry(&self, entry: &InstallationEntry) -> InstallationsResult<()> {
191        let persisted = entry.to_persisted()?;
192        self.inner
193            .persistence
194            .write(self.inner.app.name(), &persisted)
195    }
196
197    fn persist_current_state(&self) -> InstallationsResult<()> {
198        let current = self.inner.state.lock().unwrap().clone();
199        if let Some(entry) = current {
200            self.persist_entry(&entry)?;
201        }
202        Ok(())
203    }
204
205    /// Deletes the current Firebase Installation, clearing cached state and persisted data.
206    pub fn delete(&self) -> InstallationsResult<()> {
207        let entry = { self.inner.state.lock().unwrap().clone() };
208
209        if let Some(entry) = entry.clone() {
210            self.inner.rest_client.delete_installation(
211                &self.inner.config,
212                &entry.fid,
213                &entry.refresh_token,
214            )?;
215        }
216
217        self.inner.persistence.clear(self.inner.app.name())?;
218
219        {
220            let mut state = self.inner.state.lock().unwrap();
221            *state = None;
222        }
223
224        INSTALLATIONS_CACHE
225            .lock()
226            .unwrap()
227            .remove(self.inner.app.name());
228
229        Ok(())
230    }
231}
232
233fn generate_fid() -> InstallationsResult<String> {
234    let mut rng = thread_rng();
235    for _ in 0..5 {
236        let mut bytes = [0u8; 17];
237        rng.fill_bytes(&mut bytes);
238        bytes[0] = 0b0111_0000 | (bytes[0] & 0x0F);
239        let encoded = URL_SAFE_NO_PAD.encode(bytes);
240        let fid = encoded[..22].to_string();
241        if matches!(fid.chars().next(), Some('c' | 'd' | 'e' | 'f')) {
242            return Ok(fid);
243        }
244    }
245    Err(internal_error(
246        "Failed to generate a valid Firebase Installation ID",
247    ))
248}
249
250static INSTALLATIONS_COMPONENT: LazyLock<()> = LazyLock::new(|| {
251    let component = Component::new(
252        INSTALLATIONS_COMPONENT_NAME,
253        Arc::new(installations_factory),
254        ComponentType::Public,
255    )
256    .with_instantiation_mode(InstantiationMode::Lazy);
257    let _ = app::registry::register_component(component);
258});
259
260static INSTALLATIONS_INTERNAL_COMPONENT: LazyLock<()> = LazyLock::new(|| {
261    let component = Component::new(
262        INSTALLATIONS_INTERNAL_COMPONENT_NAME,
263        Arc::new(installations_internal_factory),
264        ComponentType::Private,
265    )
266    .with_instantiation_mode(InstantiationMode::Lazy);
267    let _ = app::registry::register_component(component);
268});
269
270fn installations_factory(
271    container: &crate::component::ComponentContainer,
272    _options: InstanceFactoryOptions,
273) -> Result<DynService, ComponentError> {
274    let app = container.root_service::<FirebaseApp>().ok_or_else(|| {
275        ComponentError::InitializationFailed {
276            name: INSTALLATIONS_COMPONENT_NAME.to_string(),
277            reason: "Firebase app not attached to component container".to_string(),
278        }
279    })?;
280    let installations =
281        Installations::new((*app).clone()).map_err(|err| ComponentError::InitializationFailed {
282            name: INSTALLATIONS_COMPONENT_NAME.to_string(),
283            reason: err.to_string(),
284        })?;
285    Ok(Arc::new(installations) as DynService)
286}
287
288fn ensure_registered() {
289    LazyLock::force(&INSTALLATIONS_COMPONENT);
290    LazyLock::force(&INSTALLATIONS_INTERNAL_COMPONENT);
291}
292
293pub fn register_installations_component() {
294    ensure_registered();
295}
296
297pub fn get_installations(app: Option<FirebaseApp>) -> InstallationsResult<Arc<Installations>> {
298    ensure_registered();
299    let app = match app {
300        Some(app) => app,
301        None => crate::app::api::get_app(None).map_err(|err| internal_error(err.to_string()))?,
302    };
303
304    if let Some(service) = INSTALLATIONS_CACHE.lock().unwrap().get(app.name()).cloned() {
305        return Ok(service);
306    }
307
308    let provider = app::registry::get_provider(&app, INSTALLATIONS_COMPONENT_NAME);
309    if let Some(installations) = provider.get_immediate::<Installations>() {
310        INSTALLATIONS_CACHE
311            .lock()
312            .unwrap()
313            .insert(app.name().to_string(), installations.clone());
314        return Ok(installations);
315    }
316
317    match provider.initialize::<Installations>(serde_json::Value::Null, None) {
318        Ok(instance) => {
319            INSTALLATIONS_CACHE
320                .lock()
321                .unwrap()
322                .insert(app.name().to_string(), instance.clone());
323            Ok(instance)
324        }
325        Err(crate::component::types::ComponentError::InstanceUnavailable { .. }) => {
326            if let Some(instance) = provider.get_immediate::<Installations>() {
327                INSTALLATIONS_CACHE
328                    .lock()
329                    .unwrap()
330                    .insert(app.name().to_string(), instance.clone());
331                Ok(instance)
332            } else {
333                let installations = Installations::new(app.clone()).map_err(|err| {
334                    internal_error(format!("Failed to initialize installations: {}", err))
335                })?;
336                let arc = Arc::new(installations);
337                INSTALLATIONS_CACHE
338                    .lock()
339                    .unwrap()
340                    .insert(app.name().to_string(), arc.clone());
341                Ok(arc)
342            }
343        }
344        Err(err) => Err(internal_error(err.to_string())),
345    }
346}
347
348/// Deletes the cached Firebase Installation for the given instance.
349pub fn delete_installations(installations: &Installations) -> InstallationsResult<()> {
350    installations.delete()
351}
352
353pub fn get_installations_internal(
354    app: Option<FirebaseApp>,
355) -> InstallationsResult<Arc<InstallationsInternal>> {
356    ensure_registered();
357    let app = match app {
358        Some(app) => app,
359        None => crate::app::api::get_app(None).map_err(|err| internal_error(err.to_string()))?,
360    };
361
362    let provider = app::registry::get_provider(&app, INSTALLATIONS_INTERNAL_COMPONENT_NAME);
363    if let Some(internal) = provider.get_immediate::<InstallationsInternal>() {
364        return Ok(internal);
365    }
366
367    match provider.initialize::<InstallationsInternal>(serde_json::Value::Null, None) {
368        Ok(instance) => Ok(instance),
369        Err(crate::component::types::ComponentError::InstanceUnavailable { .. }) => provider
370            .get_immediate::<InstallationsInternal>()
371            .ok_or_else(|| internal_error("Installations internal component unavailable")),
372        Err(err) => Err(internal_error(err.to_string())),
373    }
374}
375
376fn installations_internal_factory(
377    container: &crate::component::ComponentContainer,
378    _options: InstanceFactoryOptions,
379) -> Result<DynService, ComponentError> {
380    let app = container.root_service::<FirebaseApp>().ok_or_else(|| {
381        ComponentError::InitializationFailed {
382            name: INSTALLATIONS_INTERNAL_COMPONENT_NAME.to_string(),
383            reason: "Firebase app not attached to component container".to_string(),
384        }
385    })?;
386
387    let installations = get_installations(Some((*app).clone())).map_err(|err| {
388        ComponentError::InitializationFailed {
389            name: INSTALLATIONS_INTERNAL_COMPONENT_NAME.to_string(),
390            reason: err.to_string(),
391        }
392    })?;
393
394    let internal = InstallationsInternal { installations };
395
396    Ok(Arc::new(internal) as DynService)
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::app::api::initialize_app;
403    use crate::app::{FirebaseAppSettings, FirebaseOptions};
404    use httpmock::prelude::*;
405    use serde_json::json;
406    use std::fs;
407    use std::panic::{self, AssertUnwindSafe};
408    use std::path::PathBuf;
409    use std::sync::{Mutex, MutexGuard};
410    use std::time::{Duration, SystemTime};
411
412    static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
413
414    fn env_guard() -> MutexGuard<'static, ()> {
415        ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner())
416    }
417
418    fn unique_settings() -> FirebaseAppSettings {
419        use std::sync::atomic::{AtomicUsize, Ordering};
420        static COUNTER: AtomicUsize = AtomicUsize::new(0);
421        FirebaseAppSettings {
422            name: Some(format!(
423                "installations-{}",
424                COUNTER.fetch_add(1, Ordering::SeqCst)
425            )),
426            ..Default::default()
427        }
428    }
429
430    fn unique_cache_dir() -> PathBuf {
431        use std::sync::atomic::{AtomicUsize, Ordering};
432        static COUNTER: AtomicUsize = AtomicUsize::new(0);
433        let mut dir = std::env::temp_dir();
434        dir.push(format!(
435            "firebase-installations-cache-{}",
436            COUNTER.fetch_add(1, Ordering::SeqCst)
437        ));
438        let _ = fs::create_dir_all(&dir);
439        dir
440    }
441
442    fn base_options() -> FirebaseOptions {
443        FirebaseOptions {
444            api_key: Some("key".into()),
445            project_id: Some("project".into()),
446            app_id: Some("app".into()),
447            ..Default::default()
448        }
449    }
450
451    fn try_start_server() -> Option<MockServer> {
452        panic::catch_unwind(AssertUnwindSafe(|| MockServer::start())).ok()
453    }
454
455    fn setup_installations(
456        server: &MockServer,
457    ) -> (Arc<Installations>, PathBuf, String, FirebaseApp) {
458        let cache_dir = unique_cache_dir();
459        std::env::set_var("FIREBASE_INSTALLATIONS_API_URL", server.base_url());
460        std::env::set_var("FIREBASE_INSTALLATIONS_CACHE_DIR", &cache_dir);
461        let settings = unique_settings();
462        let app = initialize_app(base_options(), Some(settings.clone())).unwrap();
463        let app_name = app.name().to_string();
464        let installations = get_installations(Some(app.clone())).unwrap();
465        std::env::remove_var("FIREBASE_INSTALLATIONS_API_URL");
466        std::env::remove_var("FIREBASE_INSTALLATIONS_CACHE_DIR");
467        (installations, cache_dir, app_name, app)
468    }
469
470    #[test]
471    fn get_id_registers_installation_once() {
472        let _env_guard = env_guard();
473        let Some(server) = try_start_server() else {
474            eprintln!("Skipping get_id_registers_installation_once: unable to start mock server");
475            return;
476        };
477        let create_mock = server.mock(|when, then| {
478            when.method(POST).path("/projects/project/installations");
479            then.status(200)
480                .header("content-type", "application/json")
481                .json_body(json!({
482                    "fid": "fid-from-server",
483                    "refreshToken": "refresh",
484                    "authToken": { "token": "token", "expiresIn": "3600s" }
485                }));
486        });
487
488        let (installations, cache_dir, _app_name, _app) = setup_installations(&server);
489        let fid1 = installations.get_id().unwrap();
490        let fid2 = installations.get_id().unwrap();
491
492        let hits = create_mock.hits();
493        if hits == 0 {
494            eprintln!(
495                "Skipping hit assertion in get_id_registers_installation_once: \
496                 local HTTP requests appear to be blocked"
497            );
498            let _ = fs::remove_dir_all(cache_dir);
499            return;
500        }
501
502        assert_eq!(fid1, "fid-from-server");
503        assert_eq!(fid1, fid2);
504        assert_eq!(hits, 1);
505        let _ = fs::remove_dir_all(cache_dir);
506    }
507
508    #[test]
509    fn get_token_refreshes_when_forced() {
510        let _env_guard = env_guard();
511        let Some(server) = try_start_server() else {
512            eprintln!("Skipping get_token_refreshes_when_forced: unable to start mock server");
513            return;
514        };
515        let _create_mock = server.mock(|when, then| {
516            when.method(POST).path("/projects/project/installations");
517            then.status(200)
518                .header("content-type", "application/json")
519                .json_body(json!({
520                    "fid": "fid-from-server",
521                    "refreshToken": "refresh",
522                    "authToken": { "token": "token1", "expiresIn": "3600s" }
523                }));
524        });
525
526        let refresh_mock = server.mock(|when, then| {
527            when.method(POST)
528                .path("/projects/project/installations/fid-from-server/authTokens:generate");
529            then.status(200)
530                .header("content-type", "application/json")
531                .json_body(json!({
532                    "token": "token2",
533                    "expiresIn": "3600s"
534                }));
535        });
536
537        let (installations, cache_dir, _app_name, _app) = setup_installations(&server);
538        let token1 = installations.get_token(false).unwrap();
539        assert_eq!(token1.token, "token1");
540
541        let token2 = installations.get_token(true).unwrap();
542        assert_eq!(token2.token, "token2");
543
544        let hits = refresh_mock.hits();
545        if hits == 0 {
546            eprintln!(
547                "Skipping hit assertion in get_token_refreshes_when_forced: \
548                 local HTTP requests appear to be blocked"
549            );
550            let _ = fs::remove_dir_all(cache_dir);
551            return;
552        }
553        assert_eq!(hits, 1);
554        let _ = fs::remove_dir_all(cache_dir);
555    }
556
557    #[test]
558    fn loads_entry_from_persistence() {
559        let _env_guard = env_guard();
560        let Some(server) = try_start_server() else {
561            eprintln!("Skipping loads_entry_from_persistence: unable to start mock server");
562            return;
563        };
564
565        let create_mock = server.mock(|when, then| {
566            when.method(POST).path("/projects/project/installations");
567            then.status(200)
568                .header("content-type", "application/json")
569                .json_body(json!({
570                    "fid": "unexpected",
571                    "refreshToken": "unexpected",
572                    "authToken": { "token": "unexpected", "expiresIn": "3600s" }
573                }));
574        });
575
576        let cache_dir = unique_cache_dir();
577        let persistence = FilePersistence::new(cache_dir.clone()).unwrap();
578
579        let settings = unique_settings();
580        let app_name = settings
581            .name
582            .clone()
583            .unwrap_or_else(|| "[DEFAULT]".to_string());
584
585        let token = InstallationToken {
586            token: "cached-token".into(),
587            expires_at: SystemTime::now() + Duration::from_secs(600),
588        };
589        let persisted = PersistedInstallation {
590            fid: "cached-fid".into(),
591            refresh_token: "cached-refresh".into(),
592            auth_token: PersistedAuthToken::from_runtime(&token).unwrap(),
593        };
594        persistence.write(&app_name, &persisted).unwrap();
595
596        std::env::set_var("FIREBASE_INSTALLATIONS_API_URL", server.base_url());
597        std::env::set_var("FIREBASE_INSTALLATIONS_CACHE_DIR", &cache_dir);
598
599        let app = initialize_app(base_options(), Some(settings)).unwrap();
600        let installations = get_installations(Some(app)).unwrap();
601
602        std::env::remove_var("FIREBASE_INSTALLATIONS_API_URL");
603        std::env::remove_var("FIREBASE_INSTALLATIONS_CACHE_DIR");
604
605        let fid = installations.get_id().unwrap();
606        let cached_token = installations.get_token(false).unwrap();
607
608        let hits = create_mock.hits();
609        if hits == 0 {
610            assert_eq!(fid, "cached-fid");
611            assert_eq!(cached_token.token, "cached-token");
612        } else {
613            eprintln!(
614                "Expected no registration calls in loads_entry_from_persistence but observed {}",
615                hits
616            );
617        }
618
619        assert!(persistence.read(&app_name).unwrap().is_some());
620
621        let _ = fs::remove_dir_all(cache_dir);
622    }
623
624    #[test]
625    fn delete_removes_state_and_persistence() {
626        let _env_guard = env_guard();
627        let Some(server) = try_start_server() else {
628            eprintln!("Skipping delete_removes_state_and_persistence: unable to start mock server");
629            return;
630        };
631
632        let delete_mock = server.mock(|when, then| {
633            when.method(DELETE)
634                .path("/projects/project/installations/fid-from-server");
635            then.status(200);
636        });
637
638        let cache_dir = unique_cache_dir();
639        let persistence = FilePersistence::new(cache_dir.clone()).unwrap();
640
641        let settings = unique_settings();
642        let app_name = settings
643            .name
644            .clone()
645            .unwrap_or_else(|| "[DEFAULT]".to_string());
646
647        let token = InstallationToken {
648            token: "token1".into(),
649            expires_at: SystemTime::now() + Duration::from_secs(600),
650        };
651        let persisted = PersistedInstallation {
652            fid: "fid-from-server".into(),
653            refresh_token: "refresh".into(),
654            auth_token: PersistedAuthToken::from_runtime(&token).unwrap(),
655        };
656        persistence.write(&app_name, &persisted).unwrap();
657
658        std::env::set_var("FIREBASE_INSTALLATIONS_API_URL", server.base_url());
659        std::env::set_var("FIREBASE_INSTALLATIONS_CACHE_DIR", &cache_dir);
660
661        let app = initialize_app(base_options(), Some(settings)).unwrap();
662        let installations = get_installations(Some(app)).unwrap();
663
664        std::env::remove_var("FIREBASE_INSTALLATIONS_API_URL");
665        std::env::remove_var("FIREBASE_INSTALLATIONS_CACHE_DIR");
666
667        assert_eq!(installations.get_id().unwrap(), "fid-from-server");
668
669        installations.delete().unwrap();
670
671        let hits = delete_mock.hits();
672        if hits == 0 {
673            eprintln!(
674                "Skipping delete request assertion: local HTTP requests appear to be blocked"
675            );
676        } else {
677            assert_eq!(hits, 1);
678        }
679
680        assert!(persistence.read(&app_name).unwrap().is_none());
681
682        let recreate_mock = server.mock(|when, then| {
683            when.method(POST).path("/projects/project/installations");
684            then.status(200)
685                .header("content-type", "application/json")
686                .json_body(json!({
687                    "fid": "fid-after-delete",
688                    "refreshToken": "refresh2",
689                    "authToken": { "token": "token2", "expiresIn": "3600s" }
690                }));
691        });
692
693        let new_fid = installations.get_id().unwrap();
694        if recreate_mock.hits() == 0 {
695            eprintln!(
696                "Expected re-registration after delete but mock server did not observe the call"
697            );
698        } else {
699            assert_eq!(new_fid, "fid-after-delete");
700        }
701
702        let _ = fs::remove_dir_all(cache_dir);
703    }
704
705    #[test]
706    fn internal_component_exposes_id_and_token() {
707        let _env_guard = env_guard();
708        let Some(server) = try_start_server() else {
709            eprintln!(
710                "Skipping internal_component_exposes_id_and_token: unable to start mock server"
711            );
712            return;
713        };
714
715        let create_mock = server.mock(|when, then| {
716            when.method(POST).path("/projects/project/installations");
717            then.status(200)
718                .header("content-type", "application/json")
719                .json_body(json!({
720                    "fid": "fid-from-server",
721                    "refreshToken": "refresh",
722                    "authToken": { "token": "token", "expiresIn": "3600s" }
723                }));
724        });
725
726        let refresh_mock = server.mock(|when, then| {
727            when.method(POST)
728                .path("/projects/project/installations/fid-from-server/authTokens:generate");
729            then.status(200)
730                .header("content-type", "application/json")
731                .json_body(json!({
732                    "token": "token-internal",
733                    "expiresIn": "3600s"
734                }));
735        });
736
737        let (installations, cache_dir, _app_name, app) = setup_installations(&server);
738        let internal = get_installations_internal(Some(app)).unwrap();
739
740        if create_mock.hits() == 0 {
741            eprintln!(
742                "Skipping internal component assertions: initial registration request not observed"
743            );
744            let _ = fs::remove_dir_all(cache_dir);
745            return;
746        }
747
748        let fid_public = installations.get_id().unwrap();
749        let fid_internal = internal.get_id().unwrap();
750        assert_eq!(fid_public, fid_internal);
751
752        let token_internal = internal.get_token(true).unwrap();
753        if refresh_mock.hits() == 0 {
754            eprintln!(
755                "Skipping token assertion in internal_component_exposes_id_and_token: no request observed"
756            );
757        } else {
758            assert_eq!(token_internal.token, "token-internal");
759        }
760
761        let _ = fs::remove_dir_all(cache_dir);
762    }
763}