Skip to main content

navcore_signalk_client/
onboard.rs

1//! Getting let in, and staying that way -- the one sequence every Signal K
2//! client goes through before it has anything else to do: find a server,
3//! connect if there is already a token, ask for one otherwise, wait for a
4//! human, and start over under a fresh token if the server ever takes an
5//! old one back.
6//!
7//! [`Onboard`] is that sequence, factored out of a first client's own
8//! delta-feed module, where it was worked out and tested first. Two
9//! applications built on Signal K do not differ in how any of this goes
10//! -- only in what they do
11//! with a delta once one arrives, which stays each application's own
12//! [`Onboard::poll`] never sees a [`crate::stream::Event::Connected`],
13//! [`crate::stream::Event::Disconnected`] or
14//! [`crate::stream::Event::Unauthorized`] leak out to a caller: this module
15//! is exactly the place that already knows what each one means, and a
16//! caller with its own copy of that knowledge is the duplication this
17//! exists to end. Only [`signalk::Delta`] comes out the other side.
18//!
19//! # What stays with the application
20//!
21//! Where a device token lives on disk, what GSettings a mariner's own
22//! discovery preference is in, and how a raw address is resolved when
23//! discovery is not used -- none of that is here, the same "not this
24//! crate's job" boundary the rest of `signalk-client` already draws (see
25//! its own module doc). [`Credentials`] is the seam: an application
26//! implements four small file operations, and hands [`Onboard::seek`] a
27//! closure for finding a server however it prefers to. Everything about
28//! *sequencing* those into "connected, or why not" is what this module
29//! does instead of every application working it out for itself again.
30
31use std::sync::Arc;
32use std::sync::mpsc::{Receiver, TryRecvError, channel};
33use std::thread;
34use std::time::Duration;
35
36use crate::access::{Access, Permissions, PendingRequest, Requested, check_access, request_access};
37use crate::discovery::Server;
38use crate::stream::{Connection, Event};
39use crate::token::Token;
40use crate::trust::Trust;
41use signalk::Delta;
42
43/// How often to ask the server again whether a human has decided yet.
44const APPROVAL_POLL: Duration = Duration::from_secs(2);
45
46/// How long to keep asking before giving up on this run. Approval
47/// requires a human to act on the server's own admin interface.
48const APPROVAL_PATIENCE: Duration = Duration::from_secs(600);
49
50/// What an application keeps on this desktop so [`Onboard`] does not have
51/// to: a device token, the identity it asks under, and a request it is
52/// still waiting to hear back about. Implemented once per application --
53/// see the module's own doc for why storage itself stays out of this
54/// crate.
55///
56/// `Send + Sync + 'static`: [`Onboard::seek`] moves the implementor onto a
57/// background thread, alongside the network call it accompanies.
58pub trait Credentials: Send + Sync {
59    /// The saved device token, if there is one.
60    fn token(&self) -> Option<String>;
61    /// Saves a freshly issued token. Errors are the caller's to log: a
62    /// token that fails to save still works for the rest of this run, it
63    /// just will not survive a restart.
64    fn save_token(&self, token: &str) -> std::io::Result<std::path::PathBuf>;
65    /// Throws the saved token away, so the next start asks again.
66    fn forget_token(&self);
67    /// What this installation calls itself to the server -- made once and
68    /// kept, so the same device keeps its place in the administrator's
69    /// list across restarts.
70    fn client_id(&self) -> String;
71    /// The request `identity` is still waiting on, if one was saved.
72    fn pending_request(&self, identity: &str) -> Option<PendingRequest>;
73    /// Remembers a request just sent.
74    fn save_pending_request(&self, identity: &str, request: &PendingRequest);
75    /// Drops the record of a request once it has been decided.
76    fn clear_pending_request(&self);
77}
78
79/// How the socket itself is doing, apart from whether a human has
80/// approved this device yet -- see [`Onboarding`] for that half.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum Link {
83    /// Listening for a server, or connecting to one already found.
84    Reaching,
85    /// Connected.
86    Up,
87    /// Not connected, and why.
88    Down(String),
89}
90
91/// Where this device stands on its way to a first delta.
92///
93/// Worth showing to a caller directly: approval can take minutes, and
94/// an interface that stays empty in the meantime is indistinguishable
95/// from a broken one.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum Onboarding {
98    /// Listening for the server, or opening the connection to one.
99    Connecting,
100    /// Waiting for a human to approve this device, under the name it
101    /// asked as.
102    Asking {
103        /// What the administrator will see in the approval list.
104        device: String,
105    },
106    /// The administrator looked and said no.
107    Denied {
108        /// The name that was refused.
109        device: String,
110    },
111    /// The server already holds a request under this name that this run
112    /// has no way to poll -- typically a stray one left over from an
113    /// earlier run whose own record of it was lost. Unlike [`Self::Asking`],
114    /// this run will *not* notice an approval on its own; deciding it on
115    /// the server clears the way, but this run itself needs a restart to
116    /// try again after that.
117    AlreadyPending {
118        /// The name whose earlier request is being waited on.
119        device: String,
120    },
121    /// Gave up before ever connecting, in log words rather than
122    /// translated -- there is no small set of reasons to enumerate here,
123    /// only whatever the network or the server said.
124    GaveUp(String),
125    /// The token this installation held was refused with `401
126    /// Unauthorized`, discarded, and a fresh request just went out under
127    /// the same name.
128    Reauthorizing {
129        /// What the fresh request is going out under.
130        device: String,
131        /// Whether the discarded token's own `exp` claim said it had
132        /// already expired, read before it was thrown away. `false` also
133        /// covers a token with no expiry claim at all, refused for some
134        /// other reason -- revoked by the administrator, most likely --
135        /// that only the server's own log would show; a `401` alone
136        /// cannot tell the two apart.
137        was_expired: bool,
138    },
139}
140
141/// Whether this run has ever gotten in.
142///
143/// Once [`Link::Up`] has happened at least once, [`Onboard::stage`] stops
144/// reporting the two [`Onboarding`] states that resolve on their own --
145/// [`Onboarding::Connecting`] and [`Onboarding::GaveUp`] -- and leaves an
146/// ordinary dropped link to [`Link`] (and a caller's own "data is going
147/// stale") to show instead, so the two are never reconciled on screen at
148/// once. The states that need a human to act again -- [`Onboarding::Asking`],
149/// [`Onboarding::Denied`], [`Onboarding::AlreadyPending`],
150/// [`Onboarding::Reauthorizing`] -- are reported regardless of whether
151/// this is the first time or the fifth: needing a fresh approval reads
152/// the same either way, and a caller that only ever asks for approval
153/// once would leave a mariner staring at "reconnecting" with nothing
154/// telling them Signal K is waiting on a click.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum Stage {
157    /// Nothing has arrived yet, or is expected any moment: what to say
158    /// instead, when there is nothing else worth showing.
159    Onboarding(Onboarding),
160    /// Past all of that. [`Link`] owns the story from here.
161    Underway,
162}
163
164/// What became of asking for access.
165///
166/// Apart from [`Access`]: this also has to say "gave up after ten
167/// minutes" and "could not even ask", neither of which the server itself
168/// reports.
169enum Sought {
170    Approved(String),
171    /// A decision, worth showing differently from an outage.
172    Denied,
173    /// The server already holds a request under this identity that this
174    /// run has no id for -- see [`Onboarding::AlreadyPending`]. Nothing
175    /// went wrong; there is simply nothing left for *this run* to poll.
176    StillPending,
177    /// Could not ask, could not find out, or nobody decided in time.
178    Failed(String),
179}
180
181/// Finding a server, connecting to it, and staying let in.
182pub struct Onboard {
183    connection: Option<Connection>,
184    finding: Option<Receiver<Option<Server>>>,
185    asking: Option<Receiver<Sought>>,
186    server: Option<Server>,
187    token: Option<String>,
188    trust: Trust,
189    link: Link,
190    identity: Option<String>,
191    denied: bool,
192    already_pending: bool,
193    ever_connected: bool,
194    reauthorizing: Option<bool>,
195    credentials: Arc<dyn Credentials>,
196    human_name: &'static str,
197    permissions: Permissions,
198    log_prefix: &'static str,
199    wake: Arc<dyn Fn() + Send + Sync>,
200}
201
202impl Onboard {
203    /// Starts looking for the server. Returns at once: `find` runs on a
204    /// thread of its own, because discovery can take several seconds and
205    /// a name lookup can hang.
206    ///
207    /// `credentials` is where a token, this device's own name and a
208    /// pending request are kept -- see [`Credentials`]'s own doc.
209    /// `human_name` is what an administrator sees in the device list's
210    /// own description column, e.g. `"AIS target list"`. `log_prefix`
211    /// tags this application's own log lines, e.g. `"ais-list"`. `wake`
212    /// is passed to every [`Connection`] this opens -- see
213    /// [`Connection::open`] for what it is for.
214    #[must_use]
215    pub fn seek(
216        find: impl FnOnce() -> Option<Server> + Send + 'static,
217        credentials: Arc<dyn Credentials>,
218        trust: Trust,
219        human_name: &'static str,
220        permissions: Permissions,
221        log_prefix: &'static str,
222        wake: impl Fn() + Send + Sync + 'static,
223    ) -> Self {
224        let (sender, finding) = channel();
225        thread::spawn(move || {
226            let _ = sender.send(find());
227        });
228
229        Self {
230            connection: None,
231            finding: Some(finding),
232            asking: None,
233            server: None,
234            token: credentials.token(),
235            trust,
236            link: Link::Reaching,
237            identity: None,
238            denied: false,
239            already_pending: false,
240            ever_connected: false,
241            reauthorizing: None,
242            credentials,
243            human_name,
244            permissions,
245            log_prefix,
246            wake: Arc::new(wake),
247        }
248    }
249
250    /// How the link is doing.
251    #[must_use]
252    pub fn link(&self) -> &Link {
253        &self.link
254    }
255
256    /// The server this run found, once discovery has answered.
257    #[must_use]
258    pub fn server(&self) -> Option<&Server> {
259        self.server.as_ref()
260    }
261
262    /// The authority this run trusts -- for a caller making its own REST
263    /// calls (fetching display units, say) against the same server this
264    /// opened its stream to, rather than reloading it from disk again.
265    #[must_use]
266    pub fn trust(&self) -> &Trust {
267        &self.trust
268    }
269
270    /// Where this device stands. See [`Stage`]'s own doc for why this
271    /// stops answering [`Stage::Onboarding`] once the link has ever come
272    /// up.
273    #[must_use]
274    pub fn stage(&self) -> Stage {
275        let onboarding = if self.denied {
276            Some(Onboarding::Denied { device: self.identity.clone().unwrap_or_default() })
277        } else if self.already_pending {
278            Some(Onboarding::AlreadyPending { device: self.identity.clone().unwrap_or_default() })
279        } else if let Some(was_expired) = self.reauthorizing {
280            Some(Onboarding::Reauthorizing { device: self.identity.clone().unwrap_or_default(), was_expired })
281        } else if self.asking.is_some() {
282            Some(Onboarding::Asking { device: self.identity.clone().unwrap_or_default() })
283        } else if self.ever_connected {
284            // An ordinary drop -- the socket alone, nothing waiting on a
285            // human -- is staleness's story to tell from here on, not
286            // this one's; see `Stage`'s own doc for why the two must
287            // never be shown at once. A state that *does* need a human
288            // (denied, already pending, reauthorizing, asking) is
289            // reported above regardless of `ever_connected`: needing a
290            // fresh approval is exactly the same situation the first
291            // time or the fifth, and hiding it behind "reconnecting"
292            // would leave a mariner waiting on a click nobody told them
293            // to make.
294            None
295        } else if let Link::Down(reason) = &self.link {
296            Some(Onboarding::GaveUp(reason.clone()))
297        } else {
298            Some(Onboarding::Connecting)
299        };
300
301        onboarding.map_or(Stage::Underway, Stage::Onboarding)
302    }
303
304    /// Picks up whatever has arrived since last time, and returns every
305    /// delta read off the stream -- own ship's or another vessel's, this
306    /// module does not look. Everything else the stream can say
307    /// ([`Event::Connected`], [`Event::Disconnected`],
308    /// [`Event::Unauthorized`]) is acted on here and never handed out;
309    /// see the module's own doc for why.
310    pub fn poll(&mut self) -> Vec<Delta> {
311        self.take_server();
312        self.take_approval();
313
314        let events: Vec<Event> = match &self.connection {
315            // Drained into a batch first: reading the channel borrows the
316            // connection, and handling what comes out of it changes self.
317            Some(connection) => connection.drain().collect(),
318            None => Vec::new(),
319        };
320
321        let mut deltas = Vec::new();
322        for event in events {
323            match event {
324                Event::Connected { .. } => self.note(Link::Up),
325                Event::Disconnected { reason } => self.note(Link::Down(reason)),
326                Event::Unauthorized => self.reauthorize(),
327                Event::Delta(delta) => deltas.push(delta),
328            }
329        }
330        deltas
331    }
332
333    /// Queues `delta_json` to be sent on the wire, as-is -- for an
334    /// application that also publishes, not only reads.
335    ///
336    /// # Errors
337    ///
338    /// If there is no open connection to send it on, or the send itself
339    /// fails.
340    pub fn publish(&self, delta_json: String) -> Result<(), crate::ClientError> {
341        let connection = self
342            .connection
343            .as_ref()
344            .ok_or_else(|| crate::ClientError::Http("no connection is open".to_owned()))?;
345        connection.publish(delta_json)
346    }
347
348    /// Records a change of link state, and says so once.
349    ///
350    /// Once, not every tick: a link that is down stays down for as long
351    /// as the weather lasts, and a line a second would bury everything
352    /// else.
353    fn note(&mut self, link: Link) {
354        if self.link == link {
355            return;
356        }
357        let prefix = self.log_prefix;
358        match &link {
359            Link::Reaching => eprintln!("[{prefix}] looking for a Signal K server"),
360            Link::Up => {
361                eprintln!("[{prefix}] Signal K link up");
362                self.ever_connected = true;
363            }
364            Link::Down(reason) => eprintln!("[{prefix}] Signal K link down: {reason}"),
365        }
366        self.link = link;
367    }
368
369    /// Acts on what discovery answered: connects, or asks to be let in.
370    fn take_server(&mut self) {
371        let Some(finding) = &self.finding else {
372            return;
373        };
374        match finding.try_recv() {
375            Err(TryRecvError::Empty) => return,
376            Ok(Some(server)) => {
377                eprintln!(
378                    "[{}] found {} at {}",
379                    self.log_prefix,
380                    server.vessel_name.as_deref().unwrap_or("a vessel"),
381                    server.http_base()
382                );
383                if self.token.is_some() {
384                    self.connect(&server);
385                } else {
386                    self.ask(&server);
387                }
388                self.server = Some(server);
389            }
390            Ok(None) => self.note(Link::Down("no server answered".to_owned())),
391            Err(TryRecvError::Disconnected) => self.note(Link::Down("discovery gave up".to_owned())),
392        }
393        self.finding = None;
394    }
395
396    /// Opens the stream, with whatever token this installation holds.
397    fn connect(&mut self, server: &Server) {
398        let wake = Arc::clone(&self.wake);
399        self.connection = Some(Connection::open(&server.stream_url(), self.token.clone(), &self.trust, move || wake()));
400    }
401
402    /// Asks the server to let this installation in, on a thread of its
403    /// own -- off the main loop because the wait is for a human, and a
404    /// user interface that stopped meanwhile would look broken at
405    /// exactly the moment it is working.
406    fn ask(&mut self, server: &Server) {
407        // Cleared unconditionally, not only on the paths that mean to
408        // set it: this is the one place every kind of ask -- first-ever,
409        // a manual forget, a reauthorize after a 401 -- passes through,
410        // so it is the one place that can promise a *previous* cycle's
411        // reason never bleeds into this one. `Self::reauthorize` sets it
412        // again immediately afterwards, for its own call only.
413        self.reauthorizing = None;
414
415        let identity = self.credentials.client_id();
416        self.identity = Some(identity.clone());
417
418        if self.credentials.pending_request(&identity).is_some() {
419            eprintln!(
420                "[{}] still waiting to be let in as \"{identity}\" -- resuming the earlier request",
421                self.log_prefix
422            );
423        } else {
424            eprintln!(
425                "[{0}] no token yet -- asking to be let in as \"{identity}\".\n\
426                 [{0}] approve it in the Signal K server's Security > Devices list.",
427                self.log_prefix
428            );
429        }
430
431        let (sender, asking) = channel();
432        let http_base = server.http_base();
433        let trust = self.trust.clone();
434        let credentials = Arc::clone(&self.credentials);
435        let human_name = self.human_name;
436        let permissions = self.permissions;
437        thread::spawn(move || {
438            let _ = sender.send(seek_approval(&http_base, &identity, human_name, permissions, &trust, &*credentials));
439        });
440
441        self.asking = Some(asking);
442        self.note(Link::Reaching);
443    }
444
445    /// Reacts to [`Event::Unauthorized`]: discards the token this
446    /// connection was opened with and immediately asks for a new one,
447    /// under the same name as before.
448    ///
449    /// A `401` during the handshake means exactly one thing either way --
450    /// this token is not getting back in -- so there is nothing to wait
451    /// for the way [`Link::Down`] is: the dead connection is dropped here
452    /// rather than left to retry itself into the same wall.
453    fn reauthorize(&mut self) {
454        // Read before the token is thrown away -- once it is gone, there
455        // is nothing left to read the claim from. See
456        // [`Onboarding::Reauthorizing`]'s own doc for why this is the
457        // honest half of the story and not the whole of it.
458        let was_expired = self.token.as_deref().and_then(Token::read).is_some_and(|token| {
459            let now = std::time::SystemTime::now()
460                .duration_since(std::time::UNIX_EPOCH)
461                .map_or(0, |elapsed| elapsed.as_secs() as i64);
462            token.expired(now)
463        });
464
465        self.credentials.forget_token();
466        self.token = None;
467        // Dropping asks its own background thread to stop, but that
468        // thread has already stopped itself on this exact event -- see
469        // `crate::stream`'s own doc on why a `401` is not retried.
470        self.connection = None;
471        self.note(Link::Down(if was_expired {
472            "the token had expired".to_owned()
473        } else {
474            "the token was no longer accepted".to_owned()
475        }));
476
477        if let Some(server) = self.server.clone() {
478            self.ask(&server);
479            self.reauthorizing = Some(was_expired);
480        }
481        // If there is no server yet, there is nothing to ask -- but
482        // `Event::Unauthorized` only ever arrives from a connection this
483        // opened against a known server, so in practice this branch is
484        // never reached.
485    }
486
487    /// Picks up an approval once it has been granted, and connects.
488    fn take_approval(&mut self) {
489        let Some(asking) = &self.asking else {
490            return;
491        };
492        let device = self.identity.clone().unwrap_or_default();
493        match asking.try_recv() {
494            Err(TryRecvError::Empty) => return,
495            Ok(Sought::Approved(token)) => {
496                // Written down before anything is done with it: the
497                // server issues a token exactly once.
498                match self.credentials.save_token(&token) {
499                    Ok(path) => eprintln!("[{}] approved -- token saved in {}", self.log_prefix, path.display()),
500                    Err(error) => eprintln!(
501                        "[{}] approved, but the token could NOT be saved ({error}). \
502                         It will not survive a restart, and Signal K issues it only once.",
503                        self.log_prefix
504                    ),
505                }
506                self.token = Some(token);
507                if let Some(server) = self.server.clone() {
508                    self.connect(&server);
509                }
510            }
511            Ok(Sought::Denied) => {
512                self.denied = true;
513                self.note(Link::Down("the access request was refused".to_owned()));
514            }
515            Ok(Sought::StillPending) => {
516                // Not a link failure -- nothing was wrong with the
517                // network or the server, only a stray request from an
518                // earlier run that this one has no id to poll. Link is
519                // left alone on purpose: see `Onboarding::AlreadyPending`.
520                self.already_pending = true;
521                eprintln!(
522                    "[{}] a request for \"{device}\" is already waiting on the server; \
523                     approve or deny it there, then restart",
524                    self.log_prefix
525                );
526            }
527            Ok(Sought::Failed(reason)) => self.note(Link::Down(reason)),
528            Err(TryRecvError::Disconnected) => self.note(Link::Down("the access request gave up".to_owned())),
529        }
530        self.asking = None;
531    }
532}
533
534/// Asks to be let in, then waits for a human to decide.
535///
536/// Resumes a request already sent rather than sending a second one: a
537/// restart taken mid-wait is the ordinary way to reach this, and the
538/// server rightly refuses a second request under a name that already has
539/// one outstanding.
540fn seek_approval(
541    http_base: &str,
542    identity: &str,
543    human_name: &str,
544    permissions: Permissions,
545    trust: &Trust,
546    credentials: &dyn Credentials,
547) -> Sought {
548    let request = match credentials.pending_request(identity) {
549        Some(request) => request,
550        None => {
551            let description = format!("{human_name} ({identity})");
552            match request_access(http_base, identity, &description, permissions, trust) {
553                Ok(Requested::New(request)) => {
554                    credentials.save_pending_request(identity, &request);
555                    request
556                }
557                Ok(Requested::AlreadyPending) => return Sought::StillPending,
558                Err(error) => return Sought::Failed(format!("could not ask for access: {error}")),
559            }
560        }
561    };
562
563    let deadline = std::time::Instant::now() + APPROVAL_PATIENCE;
564    while std::time::Instant::now() < deadline {
565        match check_access(http_base, &request, trust) {
566            Ok(Access::Approved { token }) => {
567                credentials.clear_pending_request();
568                return Sought::Approved(token);
569            }
570            Ok(Access::Denied) => {
571                credentials.clear_pending_request();
572                return Sought::Denied;
573            }
574            Ok(Access::Pending) => thread::sleep(APPROVAL_POLL),
575            // Not resumed next run: a request the server can no longer
576            // answer about (found live: an HTTP 500 for one its own
577            // admin page no longer listed) would fail the same way every
578            // start, forever. Asking fresh is the better bet.
579            Err(error) => {
580                credentials.clear_pending_request();
581                return Sought::Failed(format!("asking about the request: {error}"));
582            }
583        }
584    }
585
586    credentials.clear_pending_request();
587    Sought::Failed("nobody approved the access request in time".to_owned())
588}
589
590#[cfg(test)]
591mod tests {
592    use std::sync::mpsc::channel;
593
594    use super::*;
595
596    /// A [`Credentials`] that keeps nothing -- these tests exercise
597    /// [`Onboard::stage`] and the pure half of [`Onboard::reauthorize`],
598    /// neither of which reads one back.
599    struct NullCredentials;
600
601    impl Credentials for NullCredentials {
602        fn token(&self) -> Option<String> {
603            None
604        }
605
606        fn save_token(&self, _token: &str) -> std::io::Result<std::path::PathBuf> {
607            Ok(std::path::PathBuf::new())
608        }
609
610        fn forget_token(&self) {}
611
612        fn client_id(&self) -> String {
613            "test".to_owned()
614        }
615
616        fn pending_request(&self, _identity: &str) -> Option<PendingRequest> {
617            None
618        }
619
620        fn save_pending_request(&self, _identity: &str, _request: &PendingRequest) {}
621
622        fn clear_pending_request(&self) {}
623    }
624
625    /// An [`Onboard`] in whatever state a test wants, without the thread
626    /// [`Onboard::seek`] would spawn.
627    fn onboard(link: Link) -> Onboard {
628        Onboard {
629            connection: None,
630            finding: None,
631            asking: None,
632            server: None,
633            token: None,
634            trust: Trust::plaintext(),
635            link,
636            identity: None,
637            denied: false,
638            already_pending: false,
639            ever_connected: false,
640            reauthorizing: None,
641            credentials: Arc::new(NullCredentials),
642            human_name: "Test",
643            permissions: Permissions::ReadOnly,
644            log_prefix: "test",
645            wake: Arc::new(|| {}),
646        }
647    }
648
649    #[test]
650    fn a_fresh_onboard_is_onboarding_and_connecting() {
651        assert_eq!(onboard(Link::Reaching).stage(), Stage::Onboarding(Onboarding::Connecting));
652    }
653
654    #[test]
655    fn asking_names_the_device_being_asked_for() {
656        let mut state = onboard(Link::Reaching);
657        state.identity = Some("test-device".to_owned());
658        let (_sender, asking) = channel();
659        state.asking = Some(asking);
660
661        assert_eq!(state.stage(), Stage::Onboarding(Onboarding::Asking { device: "test-device".to_owned() }));
662    }
663
664    #[test]
665    fn a_denial_is_told_apart_from_an_ordinary_outage() {
666        // Both leave the link Down with a sentence; only `denied` tells a
667        // decision from a dropped connection, and take_approval is what
668        // sets it -- checked here on the derived state rather than by
669        // reaching for the private flag.
670        let mut state = onboard(Link::Down("the access request was refused".to_owned()));
671        state.identity = Some("test-device".to_owned());
672        state.denied = true;
673
674        assert_eq!(state.stage(), Stage::Onboarding(Onboarding::Denied { device: "test-device".to_owned() }));
675    }
676
677    #[test]
678    fn reauthorizing_is_told_apart_from_a_first_ever_ask() {
679        // Both have `asking` set at the same time -- reauthorizing sets it
680        // via the same `ask` call a first-ever request uses. Only the
681        // dedicated flag tells the two apart, checked here on the derived
682        // state a caller actually sees rather than the flag itself.
683        let mut state = onboard(Link::Reaching);
684        state.identity = Some("test-device".to_owned());
685        let (_sender, asking) = channel();
686        state.asking = Some(asking);
687        state.reauthorizing = Some(true);
688
689        assert_eq!(
690            state.stage(),
691            Stage::Onboarding(Onboarding::Reauthorizing { device: "test-device".to_owned(), was_expired: true })
692        );
693    }
694
695    #[test]
696    fn a_token_refused_for_no_stated_reason_is_not_called_expired() {
697        // A 401 on a token whose own claim did not say it had expired --
698        // revoked by the administrator, most likely. Reauthorizing still
699        // happens, but `was_expired` must stay false: this cannot honestly
700        // claim to know why, only that it was refused.
701        let mut state = onboard(Link::Reaching);
702        state.identity = Some("test-device".to_owned());
703        let (_sender, asking) = channel();
704        state.asking = Some(asking);
705        state.reauthorizing = Some(false);
706
707        assert_eq!(
708            state.stage(),
709            Stage::Onboarding(Onboarding::Reauthorizing { device: "test-device".to_owned(), was_expired: false })
710        );
711    }
712
713    /// A JWT carrying `payload`, encoded the same way a real Signal K
714    /// device token is -- see [`Token::read`]'s own doc. No signature:
715    /// nothing here checks one, only reads the claims.
716    fn jwt_with(payload: &str) -> String {
717        use base64::Engine;
718        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload.as_bytes());
719        format!("header.{encoded}.signature")
720    }
721
722    #[test]
723    fn reauthorize_reads_expiry_from_the_token_it_is_about_to_discard() {
724        // No server, so `ask` (a real thread, a real HTTP call) never
725        // runs -- this exercises only the read-then-discard half of
726        // `reauthorize`.
727        let mut state = onboard(Link::Reaching);
728        state.token = Some(jwt_with(r#"{"device":"test","exp":1000}"#));
729
730        state.reauthorize();
731
732        assert_eq!(state.token, None);
733        assert_eq!(state.link, Link::Down("the token had expired".to_owned()));
734        assert_eq!(state.reauthorizing, None, "no server to ask again against");
735    }
736
737    #[test]
738    fn reauthorize_does_not_call_an_unexpired_token_expired() {
739        let mut state = onboard(Link::Reaching);
740        state.token = Some(jwt_with(r#"{"device":"test","exp":99999999999}"#));
741
742        state.reauthorize();
743
744        assert_eq!(state.link, Link::Down("the token was no longer accepted".to_owned()));
745    }
746
747    #[test]
748    fn a_denial_after_reauthorizing_is_reported_as_a_denial() {
749        // The more specific, final outcome wins over the transitional
750        // "asking again" state that led to it -- the same priority Denied
751        // and AlreadyPending already take over plain Asking.
752        let mut state = onboard(Link::Down("the access request was refused".to_owned()));
753        state.identity = Some("test-device".to_owned());
754        state.denied = true;
755        state.reauthorizing = Some(true);
756
757        assert_eq!(state.stage(), Stage::Onboarding(Onboarding::Denied { device: "test-device".to_owned() }));
758    }
759
760    #[test]
761    fn a_stray_pending_request_is_told_apart_from_a_link_failure() {
762        // Nothing here is Link::Down -- there is nothing wrong with the
763        // network or the server, only a request this run has no id to
764        // poll. GaveUp would have implied a failure worth retrying the
765        // same way; AlreadyPending is a different kind of stuck, and reads
766        // that way to a caller.
767        let mut state = onboard(Link::Reaching);
768        state.identity = Some("test-device".to_owned());
769        state.already_pending = true;
770
771        assert_eq!(state.stage(), Stage::Onboarding(Onboarding::AlreadyPending { device: "test-device".to_owned() }));
772    }
773
774    #[test]
775    fn giving_up_before_ever_connecting_is_still_onboarding() {
776        let state = onboard(Link::Down("no server answered".to_owned()));
777        assert_eq!(state.stage(), Stage::Onboarding(Onboarding::GaveUp("no server answered".to_owned())));
778    }
779
780    #[test]
781    fn once_the_link_has_ever_been_up_an_ordinary_drop_is_underway_not_onboarding() {
782        // An outage that resolves on its own: a second telling of the
783        // same loss, this time in onboarding's words instead of
784        // staleness's, would only leave the two disagreeing about the
785        // same fact.
786        let mut state = onboard(Link::Reaching);
787        state.note(Link::Up);
788        state.note(Link::Down("Signal K link down".to_owned()));
789
790        assert_eq!(state.stage(), Stage::Underway);
791    }
792
793    #[test]
794    fn reauthorizing_still_shows_after_a_previous_successful_connection() {
795        // Guards against a token revoked mid-run reading as plain
796        // "Underway" with a dropped link, indistinguishable from an
797        // ordinary outage that clears itself: a caller needs to know
798        // Signal K is waiting on a fresh approval, the same as on the
799        // first connection.
800        let mut state = onboard(Link::Reaching);
801        state.note(Link::Up);
802        state.identity = Some("test-device".to_owned());
803        state.note(Link::Down("the token was no longer accepted".to_owned()));
804        let (_sender, asking) = channel();
805        state.asking = Some(asking);
806        state.reauthorizing = Some(false);
807
808        assert_eq!(
809            state.stage(),
810            Stage::Onboarding(Onboarding::Reauthorizing { device: "test-device".to_owned(), was_expired: false })
811        );
812    }
813
814    #[test]
815    fn a_denial_still_shows_after_a_previous_successful_connection() {
816        let mut state = onboard(Link::Reaching);
817        state.note(Link::Up);
818        state.identity = Some("test-device".to_owned());
819        state.denied = true;
820
821        assert_eq!(state.stage(), Stage::Onboarding(Onboarding::Denied { device: "test-device".to_owned() }));
822    }
823
824    #[test]
825    fn an_ordinary_reconnect_after_a_previous_connection_stays_underway() {
826        // The other half of the fix: only the states that need a human
827        // are exempted from `ever_connected` -- plain `Reaching` (no
828        // token trouble, just dialling back in) must still collapse to
829        // `Underway`, or every routine wifi hiccup would wrongly read as
830        // needing an approval nobody is waiting to give.
831        let mut state = onboard(Link::Reaching);
832        state.note(Link::Up);
833        state.note(Link::Down("Signal K link down".to_owned()));
834        state.note(Link::Reaching);
835
836        assert_eq!(state.stage(), Stage::Underway);
837    }
838}