fxa_client/internal/
device.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5use std::{
6    cell::Cell,
7    collections::{HashMap, HashSet},
8};
9
10pub use super::http_client::{GetDeviceResponse as Device, PushSubscription};
11use super::{
12    commands::{self, IncomingDeviceCommand, PrivateCommandKeys, PublicCommandKeys},
13    http_client::{
14        DeviceUpdateRequest, DeviceUpdateRequestBuilder, PendingCommand, UpdateDeviceResponse,
15    },
16    scopes, telemetry, util, CachedResponse, FirefoxAccount,
17};
18use crate::{info, warn, DeviceCapability, Error, LocalDevice, Result};
19use sync15::DeviceType;
20
21// An devices response is considered fresh for `DEVICES_FRESHNESS_THRESHOLD` ms.
22const DEVICES_FRESHNESS_THRESHOLD: u64 = 60_000; // 1 minute
23
24thread_local! {
25    /// The maximum size, in bytes, of a command payload. The FxA server may
26    /// reject requests to invoke commands with payloads exceeding this size.
27    ///
28    /// Defaults to 16 KB; overridden in tests.
29    pub static COMMAND_MAX_PAYLOAD_SIZE: Cell<usize> = const { Cell::new(16 * 1024) }
30}
31
32/// The reason we are fetching commands.
33#[derive(Clone, Copy)]
34pub enum CommandFetchReason {
35    /// We are polling in-case we've missed some.
36    Poll,
37    /// We got a push notification with the index of the message.
38    Push(u64),
39}
40
41impl FirefoxAccount {
42    /// Fetches the list of devices from the current account including
43    /// the current one.
44    ///
45    /// * `ignore_cache` - If set to true, bypass the in-memory cache
46    ///   and fetch devices from the server.
47    pub fn get_devices(&mut self, ignore_cache: bool) -> Result<Vec<Device>> {
48        if let Some(d) = &self.devices_cache {
49            if !ignore_cache && util::now() < d.cached_at + DEVICES_FRESHNESS_THRESHOLD {
50                return Ok(d.response.clone());
51            }
52        }
53
54        let refresh_token = self.get_refresh_token()?;
55        let response = self
56            .client
57            .get_devices(self.state.config(), refresh_token)?;
58
59        self.devices_cache = Some(CachedResponse {
60            response: response.clone(),
61            cached_at: util::now(),
62            etag: "".into(),
63        });
64
65        Ok(response)
66    }
67
68    pub fn get_current_device(&mut self) -> Result<Option<Device>> {
69        Ok(self
70            .get_devices(false)?
71            .into_iter()
72            .find(|d| d.is_current_device))
73    }
74
75    /// Replaces the internal set of "tracked" device capabilities by re-registering
76    /// new capabilities and returns a set of device commands to register with the
77    /// server.
78    fn register_capabilities(
79        &mut self,
80        capabilities: &[DeviceCapability],
81    ) -> Result<HashMap<String, String>> {
82        let mut commands = HashMap::new();
83        for capability in capabilities.iter().collect::<HashSet<_>>() {
84            match capability {
85                DeviceCapability::SendTab => {
86                    let send_tab_command_data =
87                        self.generate_command_data(DeviceCapability::SendTab)?;
88                    commands.insert(
89                        commands::send_tab::COMMAND_NAME.to_owned(),
90                        send_tab_command_data,
91                    );
92                }
93                DeviceCapability::CloseTabs => {
94                    let close_tabs_command_data =
95                        self.generate_command_data(DeviceCapability::CloseTabs)?;
96                    commands.insert(
97                        commands::close_tabs::COMMAND_NAME.to_owned(),
98                        close_tabs_command_data,
99                    );
100                }
101            }
102        }
103        Ok(commands)
104    }
105
106    /// Initializes our own device, most of the time this will be called right after logging-in
107    /// for the first time.
108    ///
109    /// **💾 This method alters the persisted account state.**
110    pub fn initialize_device(
111        &mut self,
112        name: &str,
113        device_type: DeviceType,
114        capabilities: &[DeviceCapability],
115    ) -> Result<LocalDevice> {
116        self.state
117            .set_device_capabilities(capabilities.iter().cloned());
118        let commands = self.register_capabilities(capabilities)?;
119        let update = DeviceUpdateRequestBuilder::new()
120            .display_name(name)
121            .device_type(&device_type)
122            .available_commands(&commands)
123            .build();
124        self.update_device(update)
125    }
126
127    /// Register a set of device capabilities against the current device.
128    ///
129    /// As the only capability is Send Tab now, its command is registered with the server.
130    /// Don't forget to also call this if the Sync Keys change as they
131    /// encrypt the Send Tab command data.
132    ///
133    /// **💾 This method alters the persisted account state.**
134    pub fn ensure_capabilities(
135        &mut self,
136        capabilities: &[DeviceCapability],
137    ) -> Result<LocalDevice> {
138        self.state
139            .set_device_capabilities(capabilities.iter().cloned());
140        // Don't re-register if we already have exactly those capabilities.
141        if let Some(local_device) = self.state.server_local_device_info() {
142            if capabilities == local_device.capabilities {
143                return Ok(local_device.clone());
144            }
145        }
146        let commands = self.register_capabilities(capabilities)?;
147        let update = DeviceUpdateRequestBuilder::new()
148            .available_commands(&commands)
149            .build();
150        self.update_device(update)
151    }
152
153    /// Re-register the device capabilities, this should only be used internally.
154    pub(crate) fn reregister_current_capabilities(&mut self) -> Result<()> {
155        let capabilities: Vec<_> = self.state.device_capabilities().iter().cloned().collect();
156        let commands = self.register_capabilities(&capabilities)?;
157        let update = DeviceUpdateRequestBuilder::new()
158            .available_commands(&commands)
159            .build();
160        self.update_device(update)?;
161        Ok(())
162    }
163
164    pub(crate) fn invoke_command(
165        &self,
166        command: &str,
167        target: &Device,
168        payload: &serde_json::Value,
169        ttl: Option<u64>,
170    ) -> Result<()> {
171        let refresh_token = self.get_refresh_token()?;
172        self.client.invoke_command(
173            self.state.config(),
174            refresh_token,
175            command,
176            &target.id,
177            payload,
178            ttl,
179        )
180    }
181
182    /// Poll and parse any pending available command for our device.
183    /// This should be called semi-regularly as the main method of
184    /// commands delivery (push) can sometimes be unreliable on mobile devices.
185    /// Typically called even when a push notification is received, so that
186    /// any prior messages for which a push didn't arrive are still handled.
187    ///
188    /// **💾 This method alters the persisted account state.**
189    pub fn poll_device_commands(
190        &mut self,
191        reason: CommandFetchReason,
192    ) -> Result<Vec<IncomingDeviceCommand>> {
193        let last_command_index = self.state.last_handled_command_index().unwrap_or(0);
194        // We increment last_command_index by 1 because the server response includes the current index.
195        self.fetch_and_parse_commands(last_command_index + 1, None, reason)
196    }
197
198    pub fn get_command_for_index(&mut self, index: u64) -> Result<IncomingDeviceCommand> {
199        let refresh_token = self.get_refresh_token()?;
200        let pending_commands =
201            self.client
202                .get_pending_commands(self.state.config(), refresh_token, index, Some(1))?;
203        self.parse_commands_messages(pending_commands.messages, CommandFetchReason::Push(index))?
204            .into_iter()
205            .next()
206            .ok_or_else(|| Error::CommandNotFound)
207    }
208
209    fn fetch_and_parse_commands(
210        &mut self,
211        index: u64,
212        limit: Option<u64>,
213        reason: CommandFetchReason,
214    ) -> Result<Vec<IncomingDeviceCommand>> {
215        let refresh_token = self.get_refresh_token()?;
216        let pending_commands =
217            self.client
218                .get_pending_commands(self.state.config(), refresh_token, index, limit)?;
219        if pending_commands.messages.is_empty() {
220            return Ok(Vec::new());
221        }
222        info!("Handling {} messages", pending_commands.messages.len());
223        let device_commands = self.parse_commands_messages(pending_commands.messages, reason)?;
224        self.state
225            .set_last_handled_command_index(pending_commands.index);
226        Ok(device_commands)
227    }
228
229    fn parse_commands_messages(
230        &mut self,
231        messages: Vec<PendingCommand>,
232        reason: CommandFetchReason,
233    ) -> Result<Vec<IncomingDeviceCommand>> {
234        let devices = self.get_devices(false)?;
235        let parsed_commands = messages
236            .into_iter()
237            .filter_map(|msg| match self.parse_command(msg, &devices, reason) {
238                Ok(device_command) => Some(device_command),
239                Err(e) => {
240                    error_support::report_error!(
241                        "fxaclient-command",
242                        "Error while processing command: {}",
243                        e
244                    );
245                    None
246                }
247            })
248            .collect();
249        Ok(parsed_commands)
250    }
251
252    fn parse_command(
253        &mut self,
254        command: PendingCommand,
255        devices: &[Device],
256        reason: CommandFetchReason,
257    ) -> Result<IncomingDeviceCommand> {
258        let telem_reason = match reason {
259            CommandFetchReason::Poll => telemetry::ReceivedReason::Poll,
260            CommandFetchReason::Push(index) if command.index < index => {
261                telemetry::ReceivedReason::PushMissed
262            }
263            _ => telemetry::ReceivedReason::Push,
264        };
265        let command_data = command.data;
266        let sender = command_data
267            .sender
268            .and_then(|s| devices.iter().find(|i| i.id == s).cloned());
269        match command_data.command.as_str() {
270            commands::send_tab::COMMAND_NAME => {
271                self.handle_send_tab_command(sender, command_data.payload, telem_reason)
272            }
273            commands::close_tabs::COMMAND_NAME => {
274                self.handle_close_tabs_command(sender, command_data.payload, telem_reason)
275            }
276            _ => Err(Error::UnknownCommand(command_data.command)),
277        }
278    }
279
280    pub fn set_device_name(&mut self, name: &str) -> Result<LocalDevice> {
281        let update = DeviceUpdateRequestBuilder::new().display_name(name).build();
282        self.update_device(update)
283    }
284
285    pub fn clear_device_name(&mut self) -> Result<()> {
286        let update = DeviceUpdateRequestBuilder::new()
287            .clear_display_name()
288            .build();
289        self.update_device(update)?;
290        Ok(())
291    }
292
293    pub fn set_push_subscription(
294        &mut self,
295        push_subscription: PushSubscription,
296    ) -> Result<LocalDevice> {
297        let update = DeviceUpdateRequestBuilder::new()
298            .push_subscription(&push_subscription)
299            .build();
300        self.update_device(update)
301    }
302
303    pub(crate) fn replace_device(
304        &mut self,
305        display_name: &str,
306        device_type: &DeviceType,
307        push_subscription: &Option<PushSubscription>,
308        commands: &HashMap<String, String>,
309    ) -> Result<()> {
310        self.state.clear_server_local_device_info();
311        let mut builder = DeviceUpdateRequestBuilder::new()
312            .display_name(display_name)
313            .device_type(device_type)
314            .available_commands(commands);
315        if let Some(push_subscription) = push_subscription {
316            builder = builder.push_subscription(push_subscription)
317        }
318        self.update_device(builder.build())?;
319        Ok(())
320    }
321
322    fn update_device(&mut self, update: DeviceUpdateRequest<'_>) -> Result<LocalDevice> {
323        let refresh_token = self.get_refresh_token()?;
324        let res = self
325            .client
326            .update_device_record(self.state.config(), refresh_token, update);
327        match res {
328            Ok(resp) => {
329                self.state.set_current_device_id(resp.id.clone());
330                let local_device = LocalDevice::from(resp);
331                self.state
332                    .update_server_local_device_info(local_device.clone());
333                Ok(local_device)
334            }
335            Err(err) => {
336                // We failed to write an update to the server.
337                // Clear local state so that we'll be sure to retry later.
338                self.state.clear_server_local_device_info();
339                Err(err)
340            }
341        }
342    }
343
344    /// Retrieve the current device id from state
345    pub fn get_current_device_id(&mut self) -> Result<String> {
346        match self.state.current_device_id() {
347            Some(ref device_id) => Ok(device_id.to_string()),
348            None => Err(Error::NoCurrentDeviceId),
349        }
350    }
351
352    /// Generate the command to be registered with the server for
353    /// the given capability.
354    ///
355    /// **💾 This method alters the persisted account state.**
356    pub(crate) fn generate_command_data(&mut self, capability: DeviceCapability) -> Result<String> {
357        let own_keys = self.load_or_generate_command_keys(capability)?;
358        let public_keys: PublicCommandKeys = own_keys.into();
359        let oldsync_key = self.get_scoped_key(scopes::OLD_SYNC)?;
360        public_keys.as_command_data(oldsync_key)
361    }
362
363    fn load_or_generate_command_keys(
364        &mut self,
365        capability: DeviceCapability,
366    ) -> Result<PrivateCommandKeys> {
367        match capability {
368            DeviceCapability::SendTab => self.load_or_generate_send_tab_keys(),
369            DeviceCapability::CloseTabs => self.load_or_generate_close_tabs_keys(),
370        }
371    }
372}
373
374impl TryFrom<String> for DeviceCapability {
375    type Error = Error;
376
377    fn try_from(command: String) -> Result<Self> {
378        match command.as_str() {
379            commands::send_tab::COMMAND_NAME => Ok(DeviceCapability::SendTab),
380            commands::close_tabs::COMMAND_NAME => Ok(DeviceCapability::CloseTabs),
381            _ => Err(Error::UnknownCommand(command)),
382        }
383    }
384}
385
386impl From<UpdateDeviceResponse> for LocalDevice {
387    fn from(resp: UpdateDeviceResponse) -> Self {
388        Self {
389            id: resp.id,
390            display_name: resp.display_name,
391            device_type: resp.device_type,
392            capabilities: resp
393                .available_commands
394                .into_keys()
395                .filter_map(|command| match command.try_into() {
396                    Ok(capability) => Some(capability),
397                    Err(e) => {
398                        warn!("While parsing UpdateDeviceResponse: {e}");
399                        None
400                    }
401                })
402                .collect(),
403            push_subscription: resp.push_subscription.map(Into::into),
404            push_endpoint_expired: resp.push_endpoint_expired,
405        }
406    }
407}
408
409impl TryFrom<Device> for crate::Device {
410    type Error = Error;
411    fn try_from(d: Device) -> Result<Self> {
412        let capabilities: Vec<_> = d
413            .available_commands
414            .keys()
415            .filter_map(|k| match k.as_str() {
416                commands::send_tab::COMMAND_NAME => Some(DeviceCapability::SendTab),
417                commands::close_tabs::COMMAND_NAME => Some(DeviceCapability::CloseTabs),
418                _ => None,
419            })
420            .collect();
421        Ok(crate::Device {
422            id: d.common.id,
423            display_name: d.common.display_name,
424            device_type: d.common.device_type,
425            capabilities,
426            push_subscription: d.common.push_subscription.map(Into::into),
427            push_endpoint_expired: d.common.push_endpoint_expired,
428            is_current_device: d.is_current_device,
429            last_access_time: d.last_access_time.map(TryFrom::try_from).transpose()?,
430        })
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::internal::http_client::*;
438    use crate::internal::oauth::RefreshToken;
439    use crate::internal::Config;
440    use crate::ScopedKey;
441    use mockall::predicate::always;
442    use mockall::predicate::eq;
443    use nss::ensure_initialized;
444    use std::collections::HashSet;
445    use std::sync::Arc;
446
447    fn setup() -> FirefoxAccount {
448        ensure_initialized();
449
450        // I'd love to be able to configure a single mocked client here,
451        // but can't work out how to do that within the typesystem.
452        let config = Config::stable_dev("12345678", "https://foo.bar");
453        let mut fxa = FirefoxAccount::with_config(config);
454        fxa.state.force_refresh_token(RefreshToken {
455            token: "refreshtok".to_string(),
456            scopes: HashSet::default(),
457        });
458        fxa.state.insert_scoped_key("https://identity.mozilla.com/apps/oldsync", ScopedKey {
459            kty: "oct".to_string(),
460            scope: "https://identity.mozilla.com/apps/oldsync".to_string(),
461            k: "kMtwpVC0ZaYFJymPza8rXK_0CgCp3KMwRStwGfBRBDtL6hXRDVJgQFaoOQ2dimw0Bko5WVv2gNTy7RX5zFYZHg".to_string(),
462            kid: "1542236016429-Ox1FbJfFfwTe5t-xq4v2hQ".to_string(),
463        });
464        fxa
465    }
466
467    #[test]
468    fn test_ensure_capabilities_does_not_hit_the_server_if_nothing_has_changed() {
469        let mut fxa = setup();
470
471        // Do an initial call to ensure_capabilities().
472        let mut client = MockFxAClient::new();
473        client
474            .expect_update_device_record()
475            .with(always(), eq("refreshtok"), always())
476            .times(1)
477            .returning(|_, _, _| {
478                Ok(UpdateDeviceResponse {
479                    id: "device1".to_string(),
480                    display_name: "".to_string(),
481                    device_type: DeviceType::Desktop,
482                    push_subscription: None,
483                    available_commands: HashMap::from([(
484                        commands::send_tab::COMMAND_NAME.to_owned(),
485                        "fake-command-data".to_owned(),
486                    )]),
487                    push_endpoint_expired: false,
488                })
489            });
490        fxa.set_client(Arc::new(client));
491        fxa.ensure_capabilities(&[DeviceCapability::SendTab])
492            .unwrap();
493        let saved = fxa.to_json().unwrap();
494
495        // Do another call with the same capabilities.
496        // The MockFxAClient will panic if it tries to hit the network again, which it shouldn't.
497        fxa.ensure_capabilities(&[DeviceCapability::SendTab])
498            .unwrap();
499
500        // Do another call with the same capabilities , after restoring from disk.
501        // The MockFxAClient will panic if it tries to hit the network, which it shouldn't.
502        let mut restored = FirefoxAccount::from_json(&saved).unwrap();
503        restored.set_client(Arc::new(MockFxAClient::new()));
504        restored
505            .ensure_capabilities(&[DeviceCapability::SendTab])
506            .unwrap();
507    }
508
509    #[test]
510    fn test_ensure_capabilities_updates_the_server_if_capabilities_increase() {
511        let mut fxa = setup();
512
513        // Do an initial call to ensure_capabilities().
514        let mut client = MockFxAClient::new();
515        client
516            .expect_update_device_record()
517            .with(always(), eq("refreshtok"), always())
518            .times(1)
519            .returning(|_, _, _| {
520                Ok(UpdateDeviceResponse {
521                    id: "device1".to_string(),
522                    display_name: "".to_string(),
523                    device_type: DeviceType::Desktop,
524                    push_subscription: None,
525                    available_commands: HashMap::default(),
526                    push_endpoint_expired: false,
527                })
528            });
529        fxa.set_client(Arc::new(client));
530
531        fxa.ensure_capabilities(&[]).unwrap();
532        let saved = fxa.to_json().unwrap();
533
534        // Do another call with reduced capabilities.
535        let mut client = MockFxAClient::new();
536        client
537            .expect_update_device_record()
538            .with(always(), eq("refreshtok"), always())
539            .times(1)
540            .returning(|_, _, _| {
541                Ok(UpdateDeviceResponse {
542                    id: "device1".to_string(),
543                    display_name: "".to_string(),
544                    device_type: DeviceType::Desktop,
545                    push_subscription: None,
546                    available_commands: HashMap::from([(
547                        commands::send_tab::COMMAND_NAME.to_owned(),
548                        "fake-command-data".to_owned(),
549                    )]),
550                    push_endpoint_expired: false,
551                })
552            });
553        fxa.set_client(Arc::new(client));
554
555        fxa.ensure_capabilities(&[DeviceCapability::SendTab])
556            .unwrap();
557
558        // Do another call with the same capabilities , after restoring from disk.
559        // The MockFxAClient will panic if it tries to hit the network, which it shouldn't.
560        let mut restored = FirefoxAccount::from_json(&saved).unwrap();
561        let mut client = MockFxAClient::new();
562        client
563            .expect_update_device_record()
564            .with(always(), eq("refreshtok"), always())
565            .returning(|_, _, _| {
566                Ok(UpdateDeviceResponse {
567                    id: "device1".to_string(),
568                    display_name: "".to_string(),
569                    device_type: DeviceType::Desktop,
570                    push_subscription: None,
571                    available_commands: HashMap::from([(
572                        commands::send_tab::COMMAND_NAME.to_owned(),
573                        "fake-command-data".to_owned(),
574                    )]),
575                    push_endpoint_expired: false,
576                })
577            });
578        restored.set_client(Arc::new(client));
579
580        restored
581            .ensure_capabilities(&[DeviceCapability::SendTab])
582            .unwrap();
583    }
584
585    #[test]
586    fn test_ensure_capabilities_updates_the_server_if_capabilities_reduce() {
587        let mut fxa = setup();
588
589        // Do an initial call to ensure_capabilities().
590        let mut client = MockFxAClient::new();
591        client
592            .expect_update_device_record()
593            .with(always(), eq("refreshtok"), always())
594            .times(1)
595            .returning(|_, _, _| {
596                Ok(UpdateDeviceResponse {
597                    id: "device1".to_string(),
598                    display_name: "".to_string(),
599                    device_type: DeviceType::Desktop,
600                    push_subscription: None,
601                    available_commands: HashMap::from([(
602                        commands::send_tab::COMMAND_NAME.to_owned(),
603                        "fake-command-data".to_owned(),
604                    )]),
605                    push_endpoint_expired: false,
606                })
607            });
608        fxa.set_client(Arc::new(client));
609
610        fxa.ensure_capabilities(&[DeviceCapability::SendTab])
611            .unwrap();
612        let saved = fxa.to_json().unwrap();
613
614        // Do another call with reduced capabilities.
615        let mut client = MockFxAClient::new();
616        client
617            .expect_update_device_record()
618            .with(always(), eq("refreshtok"), always())
619            .times(1)
620            .returning(|_, _, _| {
621                Ok(UpdateDeviceResponse {
622                    id: "device1".to_string(),
623                    display_name: "".to_string(),
624                    device_type: DeviceType::Desktop,
625                    push_subscription: None,
626                    available_commands: HashMap::default(),
627                    push_endpoint_expired: false,
628                })
629            });
630        fxa.set_client(Arc::new(client));
631
632        fxa.ensure_capabilities(&[]).unwrap();
633
634        // Do another call with the same capabilities , after restoring from disk.
635        // The MockFxAClient will panic if it tries to hit the network, which it shouldn't.
636        let mut restored = FirefoxAccount::from_json(&saved).unwrap();
637        let mut client = MockFxAClient::new();
638        client
639            .expect_update_device_record()
640            .with(always(), eq("refreshtok"), always())
641            .times(1)
642            .returning(|_, _, _| {
643                Ok(UpdateDeviceResponse {
644                    id: "device1".to_string(),
645                    display_name: "".to_string(),
646                    device_type: DeviceType::Desktop,
647                    push_subscription: None,
648                    available_commands: HashMap::default(),
649                    push_endpoint_expired: false,
650                })
651            });
652        restored.set_client(Arc::new(client));
653
654        restored.ensure_capabilities(&[]).unwrap();
655    }
656
657    #[test]
658    fn test_ensure_capabilities_will_reregister_after_new_login_flow() {
659        let mut fxa = setup();
660
661        // Do an initial call to ensure_capabilities().
662        let mut client = MockFxAClient::new();
663        client
664            .expect_update_device_record()
665            .with(always(), eq("refreshtok"), always())
666            .times(1)
667            .returning(|_, _, _| {
668                Ok(UpdateDeviceResponse {
669                    id: "device1".to_string(),
670                    display_name: "".to_string(),
671                    device_type: DeviceType::Desktop,
672                    push_subscription: None,
673                    available_commands: HashMap::from([(
674                        commands::send_tab::COMMAND_NAME.to_owned(),
675                        "fake-command-data".to_owned(),
676                    )]),
677                    push_endpoint_expired: false,
678                })
679            });
680        fxa.set_client(Arc::new(client));
681        fxa.ensure_capabilities(&[DeviceCapability::SendTab])
682            .unwrap();
683
684        // Fake that we've completed a new login flow.
685        // (which annoyingly makes a bunch of network requests)
686        let mut client = MockFxAClient::new();
687        client
688            .expect_destroy_access_token()
689            .with(always(), always())
690            .times(1)
691            .returning(|_, _| {
692                Err(Error::RemoteError {
693                    code: 500,
694                    errno: 999,
695                    error: "server error".to_string(),
696                    message: "this will be ignored anyway".to_string(),
697                    info: "".to_string(),
698                })
699            });
700        client
701            .expect_get_devices()
702            .with(always(), always())
703            .times(1)
704            .returning(|_, _| {
705                Err(Error::RemoteError {
706                    code: 500,
707                    errno: 999,
708                    error: "server error".to_string(),
709                    message: "this will be ignored anyway".to_string(),
710                    info: "".to_string(),
711                })
712            });
713        client
714            .expect_destroy_refresh_token()
715            .with(always(), always())
716            .times(1)
717            .returning(|_, _| {
718                Err(Error::RemoteError {
719                    code: 500,
720                    errno: 999,
721                    error: "server error".to_string(),
722                    message: "this will be ignored anyway".to_string(),
723                    info: "".to_string(),
724                })
725            });
726        fxa.set_client(Arc::new(client));
727
728        fxa.handle_oauth_response(
729            OAuthTokenResponse {
730                keys_jwe: None,
731                refresh_token: Some("newRefreshTok".to_string()),
732                session_token: None,
733                expires_in: 12345,
734                scope: "profile".to_string(),
735                access_token: "accesstok".to_string(),
736            },
737            None,
738        )
739        .unwrap();
740
741        assert!(fxa.state.server_local_device_info().is_none());
742
743        // Do another call with the same capabilities.
744        // It should re-register, as server-side state may have changed.
745        let mut client = MockFxAClient::new();
746        client
747            .expect_update_device_record()
748            .with(always(), eq("newRefreshTok"), always())
749            .times(1)
750            .returning(|_, _, _| {
751                Ok(UpdateDeviceResponse {
752                    id: "device1".to_string(),
753                    display_name: "".to_string(),
754                    device_type: DeviceType::Desktop,
755                    push_subscription: None,
756                    available_commands: HashMap::from([(
757                        commands::send_tab::COMMAND_NAME.to_owned(),
758                        "fake-command-data".to_owned(),
759                    )]),
760                    push_endpoint_expired: false,
761                })
762            });
763        fxa.set_client(Arc::new(client));
764        fxa.ensure_capabilities(&[DeviceCapability::SendTab])
765            .unwrap();
766    }
767
768    #[test]
769    fn test_ensure_capabilities_updates_the_server_if_previous_attempt_failed() {
770        let mut fxa = setup();
771
772        // Do an initial call to ensure_capabilities(), that fails.
773        let mut client = MockFxAClient::new();
774        client
775            .expect_update_device_record()
776            .with(always(), eq("refreshtok"), always())
777            .times(1)
778            .returning(|_, _, _| {
779                Err(Error::RemoteError {
780                    code: 500,
781                    errno: 999,
782                    error: "server error".to_string(),
783                    message: "this will be ignored anyway".to_string(),
784                    info: "".to_string(),
785                })
786            });
787        fxa.set_client(Arc::new(client));
788
789        fxa.ensure_capabilities(&[DeviceCapability::SendTab])
790            .unwrap_err();
791
792        // Do another call, which should re-attempt the update.
793        let mut client = MockFxAClient::new();
794        client
795            .expect_update_device_record()
796            .with(always(), eq("refreshtok"), always())
797            .times(1)
798            .returning(|_, _, _| {
799                Ok(UpdateDeviceResponse {
800                    id: "device1".to_string(),
801                    display_name: "".to_string(),
802                    device_type: DeviceType::Desktop,
803                    push_subscription: None,
804                    available_commands: HashMap::from([(
805                        commands::send_tab::COMMAND_NAME.to_owned(),
806                        "fake-command-data".to_owned(),
807                    )]),
808                    push_endpoint_expired: false,
809                })
810            });
811        fxa.set_client(Arc::new(client));
812
813        fxa.ensure_capabilities(&[DeviceCapability::SendTab])
814            .unwrap();
815    }
816
817    #[test]
818    fn test_get_devices() {
819        let mut fxa = setup();
820        let mut client = MockFxAClient::new();
821        client
822            .expect_get_devices()
823            .with(always(), always())
824            .times(1)
825            .returning(|_, _| {
826                Ok(vec![Device {
827                    common: DeviceResponseCommon {
828                        id: "device1".into(),
829                        display_name: "".to_string(),
830                        device_type: DeviceType::Desktop,
831                        push_subscription: None,
832                        available_commands: HashMap::new(),
833                        push_endpoint_expired: true,
834                    },
835                    is_current_device: true,
836                    location: DeviceLocation {
837                        city: None,
838                        country: None,
839                        state: None,
840                        state_code: None,
841                    },
842                    last_access_time: None,
843                }])
844            });
845
846        fxa.set_client(Arc::new(client));
847        assert!(fxa.devices_cache.is_none());
848
849        assert!(fxa.get_devices(false).is_ok());
850        assert!(fxa.devices_cache.is_some());
851
852        let cache = fxa.devices_cache.clone().unwrap();
853        assert!(!cache.response.is_empty());
854        assert!(cache.cached_at > 0);
855
856        let cached_devices = cache.response;
857        assert_eq!(cached_devices[0].id, "device1".to_string());
858
859        // Check that a second call to get_devices doesn't hit the server
860        assert!(fxa.get_devices(false).is_ok());
861        assert!(fxa.devices_cache.is_some());
862
863        let cache2 = fxa.devices_cache.unwrap();
864        let cached_devices2 = cache2.response;
865
866        assert_eq!(cache.cached_at, cache2.cached_at);
867        assert_eq!(cached_devices.len(), cached_devices2.len());
868        assert_eq!(cached_devices[0].id, cached_devices2[0].id);
869    }
870
871    #[test]
872    fn test_get_devices_network_errors() {
873        let mut fxa = setup();
874        let mut client = MockFxAClient::new();
875        client
876            .expect_get_devices()
877            .with(always(), always())
878            .times(1)
879            .returning(|_, _| {
880                Err(Error::RemoteError {
881                    code: 500,
882                    errno: 101,
883                    error: "Did not work!".to_owned(),
884                    message: "Did not work!".to_owned(),
885                    info: "Did not work!".to_owned(),
886                })
887            });
888
889        fxa.set_client(Arc::new(client));
890        assert!(fxa.devices_cache.is_none());
891
892        let res = fxa.get_devices(false);
893
894        assert!(res.is_err());
895        assert!(fxa.devices_cache.is_none());
896    }
897}