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 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 }
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 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
571pub 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}