firebase_rs_sdk/installations/
api.rs

1use std::collections::HashMap;
2use std::sync::atomic::{AtomicUsize, Ordering};
3use std::sync::{Arc, LazyLock, Mutex as StdMutex};
4
5use async_lock::Mutex as AsyncMutex;
6use base64::engine::general_purpose::URL_SAFE_NO_PAD;
7use base64::Engine as _;
8use rand::{thread_rng, RngCore};
9
10use crate::app;
11use crate::app::FirebaseApp;
12use crate::component::types::{
13    ComponentError, DynService, InstanceFactoryOptions, InstantiationMode,
14};
15use crate::component::{Component, ComponentType};
16use crate::installations::config::{extract_app_config, AppConfig};
17use crate::installations::constants::{
18    INSTALLATIONS_COMPONENT_NAME, INSTALLATIONS_INTERNAL_COMPONENT_NAME,
19};
20use crate::installations::error::{internal_error, InstallationsResult};
21#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
22use crate::installations::persistence::FilePersistence;
23#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
24use crate::installations::persistence::IndexedDbPersistence;
25use crate::installations::persistence::{
26    InstallationsPersistence, PersistedAuthToken, PersistedInstallation,
27};
28use crate::installations::rest::{RegisteredInstallation, RestClient};
29use crate::installations::types::{InstallationEntryData, InstallationToken};
30use crate::platform::runtime;
31
32#[derive(Clone, Debug)]
33pub struct Installations {
34    inner: Arc<InstallationsInner>,
35}
36
37pub type IdChangeUnsubscribe = Box<dyn FnOnce()>;
38
39struct InstallationsInner {
40    app: FirebaseApp,
41    config: AppConfig,
42    rest_client: RestClient,
43    persistence: Arc<dyn InstallationsPersistence>,
44    state: AsyncMutex<CachedState>,
45    listeners: StdMutex<HashMap<usize, Arc<dyn Fn(String) + Send + Sync>>>,
46}
47
48impl std::fmt::Debug for InstallationsInner {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("InstallationsInner")
51            .field("app", &self.app)
52            .field("config", &self.config)
53            .field("rest_client", &self.rest_client)
54            .finish()
55    }
56}
57
58impl InstallationsInner {
59    fn notify_id_change(&self, fid: &str) {
60        let callbacks: Vec<Arc<dyn Fn(String) + Send + Sync>> = {
61            let listeners = self.listeners.lock().unwrap();
62            if listeners.is_empty() {
63                return;
64            }
65            listeners.values().cloned().collect()
66        };
67
68        let fid_owned = fid.to_string();
69        for callback in callbacks {
70            callback(fid_owned.clone());
71        }
72    }
73}
74
75#[derive(Clone, Debug)]
76struct InstallationEntry {
77    fid: String,
78    refresh_token: String,
79    auth_token: InstallationToken,
80}
81
82#[derive(Debug, Default)]
83struct CachedState {
84    loaded: bool,
85    initializing: bool,
86    entry: Option<InstallationEntry>,
87}
88
89enum EnsureAction {
90    Load,
91    Register,
92}
93
94async fn concurrency_yield() {
95    runtime::yield_now().await;
96}
97
98impl InstallationEntry {
99    fn from_registered(value: RegisteredInstallation) -> Self {
100        Self {
101            fid: value.fid,
102            refresh_token: value.refresh_token,
103            auth_token: value.auth_token,
104        }
105    }
106
107    fn from_persisted(value: PersistedInstallation) -> Self {
108        Self {
109            fid: value.fid,
110            refresh_token: value.refresh_token,
111            auth_token: value.auth_token.into_runtime(),
112        }
113    }
114
115    fn to_persisted(&self) -> InstallationsResult<PersistedInstallation> {
116        Ok(PersistedInstallation {
117            fid: self.fid.clone(),
118            refresh_token: self.refresh_token.clone(),
119            auth_token: PersistedAuthToken::from_runtime(&self.auth_token)?,
120        })
121    }
122
123    fn into_public(self) -> InstallationEntryData {
124        InstallationEntryData {
125            fid: self.fid,
126            refresh_token: self.refresh_token,
127            auth_token: self.auth_token,
128        }
129    }
130}
131
132#[derive(Clone, Debug)]
133pub struct InstallationsInternal {
134    installations: Arc<Installations>,
135}
136
137impl InstallationsInternal {
138    pub async fn get_id(&self) -> InstallationsResult<String> {
139        self.installations.get_id().await
140    }
141
142    pub async fn get_token(&self, force_refresh: bool) -> InstallationsResult<InstallationToken> {
143        self.installations.get_token(force_refresh).await
144    }
145
146    pub async fn get_installation_entry(&self) -> InstallationsResult<InstallationEntryData> {
147        self.installations.installation_entry().await
148    }
149}
150
151static INSTALLATIONS_CACHE: LazyLock<StdMutex<HashMap<String, Arc<Installations>>>> =
152    LazyLock::new(|| StdMutex::new(HashMap::new()));
153
154static NEXT_LISTENER_ID: LazyLock<AtomicUsize> = LazyLock::new(|| AtomicUsize::new(1));
155
156impl Installations {
157    fn new(app: FirebaseApp) -> InstallationsResult<Self> {
158        let config = extract_app_config(&app)?;
159        let rest_client = RestClient::new()?;
160        #[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
161        let persistence: Arc<dyn InstallationsPersistence> = Arc::new(IndexedDbPersistence::new());
162
163        #[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
164        let persistence: Arc<dyn InstallationsPersistence> = Arc::new(FilePersistence::default()?);
165        Ok(Self {
166            inner: Arc::new(InstallationsInner {
167                app,
168                config,
169                rest_client,
170                persistence,
171                state: AsyncMutex::new(CachedState::default()),
172                listeners: StdMutex::new(HashMap::new()),
173            }),
174        })
175    }
176
177    pub fn app(&self) -> &FirebaseApp {
178        &self.inner.app
179    }
180
181    pub async fn get_id(&self) -> InstallationsResult<String> {
182        let entry = self.ensure_entry().await?;
183        Ok(entry.fid)
184    }
185
186    /// Registers a listener that fires whenever the Installation ID changes.
187    pub fn on_id_change<F>(&self, callback: F) -> IdChangeUnsubscribe
188    where
189        F: Fn(String) + Send + Sync + 'static,
190    {
191        let id = NEXT_LISTENER_ID.fetch_add(1, Ordering::SeqCst);
192        let callback: Arc<dyn Fn(String) + Send + Sync> = Arc::new(callback);
193        {
194            let mut listeners = self.inner.listeners.lock().unwrap();
195            listeners.insert(id, callback);
196        }
197
198        let inner = Arc::clone(&self.inner);
199        Box::new(move || {
200            inner.listeners.lock().unwrap().remove(&id);
201        })
202    }
203
204    pub async fn get_token(&self, force_refresh: bool) -> InstallationsResult<InstallationToken> {
205        let entry = self.ensure_entry().await?;
206        if !force_refresh && !entry.auth_token.is_expired() {
207            return Ok(entry.auth_token.clone());
208        }
209
210        let fid = entry.fid.clone();
211        let refresh_token = entry.refresh_token.clone();
212        let new_token = self
213            .inner
214            .rest_client
215            .generate_auth_token(&self.inner.config, &fid, &refresh_token)
216            .await?;
217
218        {
219            let mut state = self.inner.state.lock().await;
220            match state.entry.as_mut() {
221                Some(stored) if stored.fid == fid => stored.auth_token = new_token.clone(),
222                Some(stored) => {
223                    *stored = InstallationEntry {
224                        fid: fid.clone(),
225                        refresh_token: refresh_token.clone(),
226                        auth_token: new_token.clone(),
227                    };
228                }
229                None => {
230                    state.entry = Some(InstallationEntry {
231                        fid: fid.clone(),
232                        refresh_token: refresh_token.clone(),
233                        auth_token: new_token.clone(),
234                    });
235                }
236            }
237        }
238
239        self.persist_current_state().await?;
240
241        Ok(new_token)
242    }
243
244    pub async fn installation_entry(&self) -> InstallationsResult<InstallationEntryData> {
245        let entry = self.ensure_entry().await?;
246        Ok(entry.into_public())
247    }
248
249    async fn ensure_entry(&self) -> InstallationsResult<InstallationEntry> {
250        loop {
251            let action = {
252                let state = self.inner.state.lock().await;
253                if let Some(entry) = state.entry.clone() {
254                    return Ok(entry);
255                }
256                if state.initializing {
257                    None
258                } else if !state.loaded {
259                    Some(EnsureAction::Load)
260                } else {
261                    Some(EnsureAction::Register)
262                }
263            };
264
265            match action {
266                None => {
267                    concurrency_yield().await;
268                    continue;
269                }
270                Some(EnsureAction::Load) => {
271                    {
272                        let mut state = self.inner.state.lock().await;
273                        if state.entry.is_some() {
274                            continue;
275                        }
276                        if state.initializing {
277                            continue;
278                        }
279                        state.loaded = true;
280                        state.initializing = true;
281                    }
282
283                    let load_result = self.inner.persistence.read(self.inner.app.name()).await;
284
285                    let persisted = {
286                        let mut state = self.inner.state.lock().await;
287                        state.initializing = false;
288                        if let Some(entry) = state.entry.clone() {
289                            return Ok(entry);
290                        }
291                        load_result?
292                    };
293
294                    if let Some(persisted) = persisted {
295                        let entry = InstallationEntry::from_persisted(persisted);
296                        let mut state = self.inner.state.lock().await;
297                        state.entry = Some(entry.clone());
298                        return Ok(entry);
299                    }
300                    // Fall through to registration on the next loop iteration.
301                }
302                Some(EnsureAction::Register) => {
303                    {
304                        let mut state = self.inner.state.lock().await;
305                        if state.entry.is_some() {
306                            continue;
307                        }
308                        if state.initializing {
309                            continue;
310                        }
311                        state.initializing = true;
312                    }
313
314                    if !self
315                        .inner
316                        .persistence
317                        .try_acquire_registration_lock(self.inner.app.name())
318                        .await?
319                    {
320                        {
321                            let mut state = self.inner.state.lock().await;
322                            state.initializing = false;
323                        }
324                        concurrency_yield().await;
325                        continue;
326                    }
327
328                    let register_result = self.register_remote_installation().await;
329
330                    let entry = {
331                        let mut state = self.inner.state.lock().await;
332                        state.initializing = false;
333                        if let Some(entry) = state.entry.clone() {
334                            let _ = self
335                                .inner
336                                .persistence
337                                .release_registration_lock(self.inner.app.name())
338                                .await;
339                            return Ok(entry);
340                        }
341                        let registered = match register_result {
342                            Ok(value) => value,
343                            Err(err) => {
344                                let _ = self
345                                    .inner
346                                    .persistence
347                                    .release_registration_lock(self.inner.app.name())
348                                    .await;
349                                return Err(err);
350                            }
351                        };
352                        state.entry = Some(registered.clone());
353                        state.loaded = true;
354                        registered
355                    };
356
357                    if let Err(err) = self.persist_entry(&entry).await {
358                        let _ = self
359                            .inner
360                            .persistence
361                            .release_registration_lock(self.inner.app.name())
362                            .await;
363                        return Err(err);
364                    }
365                    self.inner
366                        .persistence
367                        .release_registration_lock(self.inner.app.name())
368                        .await?;
369                    self.inner.notify_id_change(&entry.fid);
370                    return Ok(entry);
371                }
372            }
373        }
374    }
375
376    async fn register_remote_installation(&self) -> InstallationsResult<InstallationEntry> {
377        let fid = generate_fid()?;
378        let registered = self
379            .inner
380            .rest_client
381            .register_installation(&self.inner.config, &fid)
382            .await?;
383        Ok(InstallationEntry::from_registered(registered))
384    }
385
386    async fn persist_entry(&self, entry: &InstallationEntry) -> InstallationsResult<()> {
387        let persisted = entry.to_persisted()?;
388        self.inner
389            .persistence
390            .write(self.inner.app.name(), &persisted)
391            .await
392    }
393
394    async fn persist_current_state(&self) -> InstallationsResult<()> {
395        let current = {
396            let state = self.inner.state.lock().await;
397            state.entry.clone()
398        };
399        if let Some(entry) = current {
400            self.persist_entry(&entry).await?;
401        }
402        Ok(())
403    }
404
405    /// Deletes the current Firebase Installation, clearing cached state and persisted data.
406    pub async fn delete(&self) -> InstallationsResult<()> {
407        let entry = {
408            let state = self.inner.state.lock().await;
409            state.entry.clone()
410        };
411
412        if let Some(entry) = entry.clone() {
413            self.inner
414                .rest_client
415                .delete_installation(&self.inner.config, &entry.fid, &entry.refresh_token)
416                .await?;
417        }
418
419        self.inner.persistence.clear(self.inner.app.name()).await?;
420
421        {
422            let mut state = self.inner.state.lock().await;
423            state.entry = None;
424            state.loaded = true;
425            state.initializing = false;
426        }
427
428        let _ = self
429            .inner
430            .persistence
431            .release_registration_lock(self.inner.app.name())
432            .await;
433
434        INSTALLATIONS_CACHE
435            .lock()
436            .unwrap()
437            .remove(self.inner.app.name());
438
439        Ok(())
440    }
441}
442
443fn generate_fid() -> InstallationsResult<String> {
444    let mut rng = thread_rng();
445    for _ in 0..5 {
446        let mut bytes = [0u8; 17];
447        rng.fill_bytes(&mut bytes);
448        bytes[0] = 0b0111_0000 | (bytes[0] & 0x0F);
449        let encoded = URL_SAFE_NO_PAD.encode(bytes);
450        let fid = encoded[..22].to_string();
451        if matches!(fid.chars().next(), Some('c' | 'd' | 'e' | 'f')) {
452            return Ok(fid);
453        }
454    }
455    Err(internal_error(
456        "Failed to generate a valid Firebase Installation ID",
457    ))
458}
459
460static INSTALLATIONS_COMPONENT: LazyLock<()> = LazyLock::new(|| {
461    let component = Component::new(
462        INSTALLATIONS_COMPONENT_NAME,
463        Arc::new(installations_factory),
464        ComponentType::Public,
465    )
466    .with_instantiation_mode(InstantiationMode::Lazy);
467    let _ = app::register_component(component);
468});
469
470static INSTALLATIONS_INTERNAL_COMPONENT: LazyLock<()> = LazyLock::new(|| {
471    let component = Component::new(
472        INSTALLATIONS_INTERNAL_COMPONENT_NAME,
473        Arc::new(installations_internal_factory),
474        ComponentType::Private,
475    )
476    .with_instantiation_mode(InstantiationMode::Lazy);
477    let _ = app::register_component(component);
478});
479
480fn installations_factory(
481    container: &crate::component::ComponentContainer,
482    _options: InstanceFactoryOptions,
483) -> Result<DynService, ComponentError> {
484    let app = container.root_service::<FirebaseApp>().ok_or_else(|| {
485        ComponentError::InitializationFailed {
486            name: INSTALLATIONS_COMPONENT_NAME.to_string(),
487            reason: "Firebase app not attached to component container".to_string(),
488        }
489    })?;
490    let installations =
491        Installations::new((*app).clone()).map_err(|err| ComponentError::InitializationFailed {
492            name: INSTALLATIONS_COMPONENT_NAME.to_string(),
493            reason: err.to_string(),
494        })?;
495    Ok(Arc::new(installations) as DynService)
496}
497
498fn ensure_registered() {
499    LazyLock::force(&INSTALLATIONS_COMPONENT);
500    LazyLock::force(&INSTALLATIONS_INTERNAL_COMPONENT);
501}
502
503pub fn register_installations_component() {
504    ensure_registered();
505}
506
507pub fn get_installations(app: Option<FirebaseApp>) -> InstallationsResult<Arc<Installations>> {
508    ensure_registered();
509    let app = match app {
510        Some(app) => app,
511        None => {
512            #[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
513            {
514                return Err(internal_error(
515                    "get_installations(None) is not supported on wasm; pass a FirebaseApp",
516                ));
517            }
518            #[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
519            {
520                use futures::executor::block_on;
521                block_on(crate::app::get_app(None))
522                    .map_err(|err| internal_error(err.to_string()))?
523            }
524        }
525    };
526
527    if let Some(service) = INSTALLATIONS_CACHE.lock().unwrap().get(app.name()).cloned() {
528        return Ok(service);
529    }
530
531    let provider = app::get_provider(&app, INSTALLATIONS_COMPONENT_NAME);
532    if let Some(installations) = provider.get_immediate::<Installations>() {
533        INSTALLATIONS_CACHE
534            .lock()
535            .unwrap()
536            .insert(app.name().to_string(), installations.clone());
537        return Ok(installations);
538    }
539
540    match provider.initialize::<Installations>(serde_json::Value::Null, None) {
541        Ok(instance) => {
542            INSTALLATIONS_CACHE
543                .lock()
544                .unwrap()
545                .insert(app.name().to_string(), instance.clone());
546            Ok(instance)
547        }
548        Err(crate::component::types::ComponentError::InstanceUnavailable { .. }) => {
549            if let Some(instance) = provider.get_immediate::<Installations>() {
550                INSTALLATIONS_CACHE
551                    .lock()
552                    .unwrap()
553                    .insert(app.name().to_string(), instance.clone());
554                Ok(instance)
555            } else {
556                let installations = Installations::new(app.clone()).map_err(|err| {
557                    internal_error(format!("Failed to initialize installations: {}", err))
558                })?;
559                let arc = Arc::new(installations);
560                INSTALLATIONS_CACHE
561                    .lock()
562                    .unwrap()
563                    .insert(app.name().to_string(), arc.clone());
564                Ok(arc)
565            }
566        }
567        Err(err) => Err(internal_error(err.to_string())),
568    }
569}
570
571/// Deletes the cached Firebase Installation for the given instance.
572pub async fn delete_installations(installations: &Installations) -> InstallationsResult<()> {
573    installations.delete().await
574}
575
576pub fn get_installations_internal(
577    app: Option<FirebaseApp>,
578) -> InstallationsResult<Arc<InstallationsInternal>> {
579    ensure_registered();
580    let app = match app {
581        Some(app) => app,
582        None => {
583            #[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
584            {
585                return Err(internal_error(
586                    "get_installations_internal(None) is not supported on wasm; pass a FirebaseApp",
587                ));
588            }
589            #[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
590            {
591                use futures::executor::block_on;
592                block_on(crate::app::get_app(None))
593                    .map_err(|err| internal_error(err.to_string()))?
594            }
595        }
596    };
597
598    let provider = app::get_provider(&app, INSTALLATIONS_INTERNAL_COMPONENT_NAME);
599    if let Some(internal) = provider.get_immediate::<InstallationsInternal>() {
600        return Ok(internal);
601    }
602
603    match provider.initialize::<InstallationsInternal>(serde_json::Value::Null, None) {
604        Ok(instance) => Ok(instance),
605        Err(crate::component::types::ComponentError::InstanceUnavailable { .. }) => provider
606            .get_immediate::<InstallationsInternal>()
607            .ok_or_else(|| internal_error("Installations internal component unavailable")),
608        Err(err) => Err(internal_error(err.to_string())),
609    }
610}
611
612fn installations_internal_factory(
613    container: &crate::component::ComponentContainer,
614    _options: InstanceFactoryOptions,
615) -> Result<DynService, ComponentError> {
616    let app = container.root_service::<FirebaseApp>().ok_or_else(|| {
617        ComponentError::InitializationFailed {
618            name: INSTALLATIONS_INTERNAL_COMPONENT_NAME.to_string(),
619            reason: "Firebase app not attached to component container".to_string(),
620        }
621    })?;
622
623    let installations = get_installations(Some((*app).clone())).map_err(|err| {
624        ComponentError::InitializationFailed {
625            name: INSTALLATIONS_INTERNAL_COMPONENT_NAME.to_string(),
626            reason: err.to_string(),
627        }
628    })?;
629
630    let internal = InstallationsInternal { installations };
631
632    Ok(Arc::new(internal) as DynService)
633}
634
635#[cfg(all(test, not(target_arch = "wasm32")))]
636mod tests {
637    use super::*;
638    use crate::app::initialize_app;
639    use crate::app::{FirebaseAppSettings, FirebaseOptions};
640    use httpmock::prelude::*;
641    use serde_json::json;
642    use std::fs;
643    use std::panic::{self, AssertUnwindSafe};
644    use std::path::PathBuf;
645    use std::sync::{Arc, Mutex, MutexGuard};
646    use std::time::{Duration, SystemTime};
647
648    static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
649
650    fn env_guard() -> MutexGuard<'static, ()> {
651        ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner())
652    }
653
654    fn unique_settings() -> FirebaseAppSettings {
655        use std::sync::atomic::{AtomicUsize, Ordering};
656        static COUNTER: AtomicUsize = AtomicUsize::new(0);
657        FirebaseAppSettings {
658            name: Some(format!(
659                "installations-{}",
660                COUNTER.fetch_add(1, Ordering::SeqCst)
661            )),
662            ..Default::default()
663        }
664    }
665
666    fn unique_cache_dir() -> PathBuf {
667        use std::sync::atomic::{AtomicUsize, Ordering};
668        static COUNTER: AtomicUsize = AtomicUsize::new(0);
669        let mut dir = std::env::temp_dir();
670        dir.push(format!(
671            "firebase-installations-cache-{}",
672            COUNTER.fetch_add(1, Ordering::SeqCst)
673        ));
674        let _ = fs::create_dir_all(&dir);
675        dir
676    }
677
678    fn base_options() -> FirebaseOptions {
679        FirebaseOptions {
680            api_key: Some("key".into()),
681            project_id: Some("project".into()),
682            app_id: Some("app".into()),
683            ..Default::default()
684        }
685    }
686
687    fn try_start_server() -> Option<MockServer> {
688        panic::catch_unwind(AssertUnwindSafe(|| MockServer::start())).ok()
689    }
690
691    async fn setup_installations(
692        server: &MockServer,
693    ) -> (Arc<Installations>, PathBuf, String, FirebaseApp) {
694        let cache_dir = unique_cache_dir();
695        std::env::set_var("FIREBASE_INSTALLATIONS_API_URL", server.base_url());
696        std::env::set_var("FIREBASE_INSTALLATIONS_CACHE_DIR", &cache_dir);
697        let settings = unique_settings();
698        let app = initialize_app(base_options(), Some(settings.clone()))
699            .await
700            .unwrap();
701        let app_name = app.name().to_string();
702        let installations = get_installations(Some(app.clone())).unwrap();
703        std::env::remove_var("FIREBASE_INSTALLATIONS_API_URL");
704        std::env::remove_var("FIREBASE_INSTALLATIONS_CACHE_DIR");
705        (installations, cache_dir, app_name, app)
706    }
707
708    #[tokio::test(flavor = "current_thread")]
709    async fn get_id_registers_installation_once() {
710        let _env_guard = env_guard();
711        let Some(server) = try_start_server() else {
712            eprintln!("Skipping get_id_registers_installation_once: unable to start mock server");
713            return;
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 (installations, cache_dir, _app_name, _app) = setup_installations(&server).await;
727        let fid1 = installations.get_id().await.unwrap();
728        let fid2 = installations.get_id().await.unwrap();
729
730        let hits = create_mock.hits();
731        if hits == 0 {
732            eprintln!(
733                "Skipping hit assertion in get_id_registers_installation_once: \
734                 local HTTP requests appear to be blocked"
735            );
736            let _ = fs::remove_dir_all(cache_dir);
737            return;
738        }
739
740        assert_eq!(fid1, "fid-from-server");
741        assert_eq!(fid1, fid2);
742        assert_eq!(hits, 1);
743        let _ = fs::remove_dir_all(cache_dir);
744    }
745
746    #[tokio::test(flavor = "current_thread")]
747    async fn on_id_change_notifies_after_registration() {
748        let _env_guard = env_guard();
749        let Some(server) = try_start_server() else {
750            eprintln!(
751                "Skipping on_id_change_notifies_after_registration: unable to start mock server"
752            );
753            return;
754        };
755        let create_mock = server.mock(|when, then| {
756            when.method(POST).path("/projects/project/installations");
757            then.status(200)
758                .header("content-type", "application/json")
759                .json_body(json!({
760                    "fid": "fid-from-server",
761                    "refreshToken": "refresh",
762                    "authToken": { "token": "token", "expiresIn": "3600s" }
763                }));
764        });
765
766        let (installations, cache_dir, _app_name, _app) = setup_installations(&server).await;
767        let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
768        let listener_capture = captured.clone();
769        let unsubscribe = installations.on_id_change(move |fid| {
770            listener_capture.lock().unwrap().push(fid);
771        });
772
773        let fid = installations.get_id().await.unwrap();
774        unsubscribe();
775
776        let hits = create_mock.hits();
777        if hits == 0 {
778            eprintln!(
779                "Skipping listener assertion in on_id_change_notifies_after_registration: local HTTP requests appear to be blocked"
780            );
781            let _ = fs::remove_dir_all(cache_dir);
782            return;
783        }
784
785        let observed = captured.lock().unwrap();
786        assert_eq!(observed.as_slice(), &[fid.clone()]);
787
788        let _ = fs::remove_dir_all(cache_dir);
789    }
790
791    #[tokio::test(flavor = "current_thread")]
792    async fn get_token_refreshes_when_forced() {
793        let _env_guard = env_guard();
794        let Some(server) = try_start_server() else {
795            eprintln!("Skipping get_token_refreshes_when_forced: unable to start mock server");
796            return;
797        };
798        let _create_mock = server.mock(|when, then| {
799            when.method(POST).path("/projects/project/installations");
800            then.status(200)
801                .header("content-type", "application/json")
802                .json_body(json!({
803                    "fid": "fid-from-server",
804                    "refreshToken": "refresh",
805                    "authToken": { "token": "token1", "expiresIn": "3600s" }
806                }));
807        });
808
809        let refresh_mock = server.mock(|when, then| {
810            when.method(POST)
811                .path("/projects/project/installations/fid-from-server/authTokens:generate");
812            then.status(200)
813                .header("content-type", "application/json")
814                .json_body(json!({
815                    "token": "token2",
816                    "expiresIn": "3600s"
817                }));
818        });
819
820        let (installations, cache_dir, _app_name, _app) = setup_installations(&server).await;
821        let token1 = installations.get_token(false).await.unwrap();
822        assert_eq!(token1.token, "token1");
823
824        let token2 = installations.get_token(true).await.unwrap();
825        assert_eq!(token2.token, "token2");
826
827        let hits = refresh_mock.hits();
828        if hits == 0 {
829            eprintln!(
830                "Skipping hit assertion in get_token_refreshes_when_forced: \
831                 local HTTP requests appear to be blocked"
832            );
833            let _ = fs::remove_dir_all(cache_dir);
834            return;
835        }
836        assert_eq!(hits, 1);
837        let _ = fs::remove_dir_all(cache_dir);
838    }
839
840    #[tokio::test(flavor = "current_thread")]
841    async fn loads_entry_from_persistence() {
842        let _env_guard = env_guard();
843        let Some(server) = try_start_server() else {
844            eprintln!("Skipping loads_entry_from_persistence: unable to start mock server");
845            return;
846        };
847
848        let create_mock = server.mock(|when, then| {
849            when.method(POST).path("/projects/project/installations");
850            then.status(200)
851                .header("content-type", "application/json")
852                .json_body(json!({
853                    "fid": "unexpected",
854                    "refreshToken": "unexpected",
855                    "authToken": { "token": "unexpected", "expiresIn": "3600s" }
856                }));
857        });
858
859        let cache_dir = unique_cache_dir();
860        let persistence = FilePersistence::new(cache_dir.clone()).unwrap();
861
862        let settings = unique_settings();
863        let app_name = settings
864            .name
865            .clone()
866            .unwrap_or_else(|| "[DEFAULT]".to_string());
867
868        let token = InstallationToken {
869            token: "cached-token".into(),
870            expires_at: SystemTime::now() + Duration::from_secs(600),
871        };
872        let persisted = PersistedInstallation {
873            fid: "cached-fid".into(),
874            refresh_token: "cached-refresh".into(),
875            auth_token: PersistedAuthToken::from_runtime(&token).unwrap(),
876        };
877        persistence.write(&app_name, &persisted).await.unwrap();
878
879        std::env::set_var("FIREBASE_INSTALLATIONS_API_URL", server.base_url());
880        std::env::set_var("FIREBASE_INSTALLATIONS_CACHE_DIR", &cache_dir);
881
882        let app = initialize_app(base_options(), Some(settings))
883            .await
884            .unwrap();
885        let installations = get_installations(Some(app)).unwrap();
886
887        std::env::remove_var("FIREBASE_INSTALLATIONS_API_URL");
888        std::env::remove_var("FIREBASE_INSTALLATIONS_CACHE_DIR");
889
890        let fid = installations.get_id().await.unwrap();
891        let cached_token = installations.get_token(false).await.unwrap();
892
893        let hits = create_mock.hits();
894        if hits == 0 {
895            assert_eq!(fid, "cached-fid");
896            assert_eq!(cached_token.token, "cached-token");
897        } else {
898            eprintln!(
899                "Expected no registration calls in loads_entry_from_persistence but observed {}",
900                hits
901            );
902        }
903
904        assert!(persistence.read(&app_name).await.unwrap().is_some());
905
906        let _ = fs::remove_dir_all(cache_dir);
907    }
908
909    #[tokio::test(flavor = "current_thread")]
910    async fn delete_removes_state_and_persistence() {
911        let _env_guard = env_guard();
912        let Some(server) = try_start_server() else {
913            eprintln!("Skipping delete_removes_state_and_persistence: unable to start mock server");
914            return;
915        };
916
917        let delete_mock = server.mock(|when, then| {
918            when.method(DELETE)
919                .path("/projects/project/installations/fid-from-server");
920            then.status(200);
921        });
922
923        let cache_dir = unique_cache_dir();
924        let persistence = FilePersistence::new(cache_dir.clone()).unwrap();
925
926        let settings = unique_settings();
927        let app_name = settings
928            .name
929            .clone()
930            .unwrap_or_else(|| "[DEFAULT]".to_string());
931
932        let token = InstallationToken {
933            token: "token1".into(),
934            expires_at: SystemTime::now() + Duration::from_secs(600),
935        };
936        let persisted = PersistedInstallation {
937            fid: "fid-from-server".into(),
938            refresh_token: "refresh".into(),
939            auth_token: PersistedAuthToken::from_runtime(&token).unwrap(),
940        };
941        persistence.write(&app_name, &persisted).await.unwrap();
942
943        std::env::set_var("FIREBASE_INSTALLATIONS_API_URL", server.base_url());
944        std::env::set_var("FIREBASE_INSTALLATIONS_CACHE_DIR", &cache_dir);
945
946        let app = initialize_app(base_options(), Some(settings))
947            .await
948            .unwrap();
949        let installations = get_installations(Some(app)).unwrap();
950
951        std::env::remove_var("FIREBASE_INSTALLATIONS_API_URL");
952        std::env::remove_var("FIREBASE_INSTALLATIONS_CACHE_DIR");
953
954        assert_eq!(installations.get_id().await.unwrap(), "fid-from-server");
955
956        installations.delete().await.unwrap();
957
958        let hits = delete_mock.hits();
959        if hits == 0 {
960            eprintln!(
961                "Skipping delete request assertion: local HTTP requests appear to be blocked"
962            );
963        } else {
964            assert_eq!(hits, 1);
965        }
966
967        assert!(persistence.read(&app_name).await.unwrap().is_none());
968
969        let recreate_mock = server.mock(|when, then| {
970            when.method(POST).path("/projects/project/installations");
971            then.status(200)
972                .header("content-type", "application/json")
973                .json_body(json!({
974                    "fid": "fid-after-delete",
975                    "refreshToken": "refresh2",
976                    "authToken": { "token": "token2", "expiresIn": "3600s" }
977                }));
978        });
979
980        let new_fid = installations.get_id().await.unwrap();
981        if recreate_mock.hits() == 0 {
982            eprintln!(
983                "Expected re-registration after delete but mock server did not observe the call"
984            );
985        } else {
986            assert_eq!(new_fid, "fid-after-delete");
987        }
988
989        let _ = fs::remove_dir_all(cache_dir);
990    }
991
992    #[tokio::test(flavor = "current_thread")]
993    async fn internal_component_exposes_id_and_token() {
994        let _env_guard = env_guard();
995        let Some(server) = try_start_server() else {
996            eprintln!(
997                "Skipping internal_component_exposes_id_and_token: unable to start mock server"
998            );
999            return;
1000        };
1001
1002        let create_mock = server.mock(|when, then| {
1003            when.method(POST).path("/projects/project/installations");
1004            then.status(200)
1005                .header("content-type", "application/json")
1006                .json_body(json!({
1007                    "fid": "fid-from-server",
1008                    "refreshToken": "refresh",
1009                    "authToken": { "token": "token", "expiresIn": "3600s" }
1010                }));
1011        });
1012
1013        let refresh_mock = server.mock(|when, then| {
1014            when.method(POST)
1015                .path("/projects/project/installations/fid-from-server/authTokens:generate");
1016            then.status(200)
1017                .header("content-type", "application/json")
1018                .json_body(json!({
1019                    "token": "token-internal",
1020                    "expiresIn": "3600s"
1021                }));
1022        });
1023
1024        let (installations, cache_dir, _app_name, app) = setup_installations(&server).await;
1025        let internal = get_installations_internal(Some(app)).unwrap();
1026
1027        if create_mock.hits() == 0 {
1028            eprintln!(
1029                "Skipping internal component assertions: initial registration request not observed"
1030            );
1031            let _ = fs::remove_dir_all(cache_dir);
1032            return;
1033        }
1034
1035        let fid_public = installations.get_id().await.unwrap();
1036        let fid_internal = internal.get_id().await.unwrap();
1037        assert_eq!(fid_public, fid_internal);
1038
1039        let token_internal = internal.get_token(true).await.unwrap();
1040        if refresh_mock.hits() == 0 {
1041            eprintln!(
1042                "Skipping token assertion in internal_component_exposes_id_and_token: no request observed"
1043            );
1044        } else {
1045            assert_eq!(token_internal.token, "token-internal");
1046        }
1047
1048        let _ = fs::remove_dir_all(cache_dir);
1049    }
1050}