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}