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(®istered)?;
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 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
348pub 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}