Skip to main content

nautilus_network/websocket/
auth.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Authentication state tracking for WebSocket clients.
17//!
18//! This module provides a robust authentication tracker that coordinates login attempts
19//! and ensures each attempt produces a fresh success or failure signal before operations
20//! resume. It follows a proven pattern used in production.
21//!
22//! # Key Features
23//!
24//! - **Three-state model**: `Unauthenticated`, `Authenticated`, `Failed` via `AuthState` enum.
25//! - **Oneshot signaling**: Each auth attempt gets a dedicated channel for result notification.
26//! - **Superseding logic**: New authentication requests cancel pending ones.
27//! - **Timeout handling**: Configurable timeout for authentication responses.
28//! - **Generic error mapping**: Adapters can map to their specific error types.
29//! - **Auth-gated waiting**: `wait_for_authenticated()` blocks until auth completes or fails.
30//!
31//! # Recommended Integration Pattern
32//!
33//! Based on production usage, the recommended pattern is:
34//!
35//! 1. **Order operations**: Call `wait_for_authenticated()` before private operations.
36//!    This waits for re-auth after reconnection instead of rejecting immediately.
37//! 2. **Reconnection flow**: Authenticate BEFORE resubscribing to topics.
38//! 3. **Event propagation**: Send auth failures through event channels to consumers.
39//! 4. **State lifecycle**: Call `invalidate()` on reconnectable connection drops,
40//!    and `fail()` on terminal auth rejection or terminal client shutdown.
41
42use std::{
43    pin::pin,
44    sync::{
45        Arc, Mutex,
46        atomic::{AtomicU8, Ordering},
47    },
48    time::Duration,
49};
50
51pub type AuthResultSender = tokio::sync::oneshot::Sender<Result<(), String>>;
52pub type AuthResultReceiver = tokio::sync::oneshot::Receiver<Result<(), String>>;
53
54/// Authentication state for a WebSocket session.
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
56#[repr(u8)]
57pub enum AuthState {
58    /// Not authenticated (initial state, after invalidate/begin).
59    #[default]
60    Unauthenticated = 0,
61    /// Successfully authenticated (after succeed).
62    Authenticated = 1,
63    /// Authentication failed or became impossible (after fail).
64    Failed = 2,
65}
66
67impl AuthState {
68    #[inline]
69    #[must_use]
70    #[expect(
71        clippy::match_same_arms,
72        reason = "explicit variant listing is clearer than collapsing 0 with wildcard"
73    )]
74    fn from_u8(value: u8) -> Self {
75        match value {
76            0 => Self::Unauthenticated,
77            1 => Self::Authenticated,
78            2 => Self::Failed,
79            _ => Self::Unauthenticated,
80        }
81    }
82
83    #[inline]
84    #[must_use]
85    const fn as_u8(self) -> u8 {
86        self as u8
87    }
88}
89
90/// Generic authentication state tracker for WebSocket connections.
91///
92/// Coordinates authentication attempts by providing a channel-based signaling
93/// mechanism. Each authentication attempt receives a dedicated oneshot channel
94/// that will be resolved when the server responds.
95///
96/// # State Management
97///
98/// The tracker maintains a three-state machine:
99/// - `Unauthenticated`: after `begin()`, `invalidate()`, or initial construction.
100/// - `Authenticated`: after `succeed()`. Queryable via `is_authenticated()`.
101/// - `Failed`: after `fail()`. Causes `wait_for_authenticated()` to return early.
102///
103/// # Superseding Behavior
104///
105/// If a new authentication attempt begins while a previous one is pending,
106/// the old attempt is automatically cancelled with an error. This prevents
107/// auth response race conditions during rapid reconnections.
108///
109/// # Thread Safety
110///
111/// All operations are thread-safe and can be called concurrently from multiple tasks.
112#[derive(Clone, Debug)]
113pub struct AuthTracker {
114    tx: Arc<Mutex<Option<AuthResultSender>>>,
115    state: Arc<AtomicU8>,
116    state_notify: Arc<tokio::sync::Notify>,
117}
118
119impl AuthTracker {
120    /// Creates a new authentication tracker.
121    #[must_use]
122    pub fn new() -> Self {
123        Self {
124            tx: Arc::new(Mutex::new(None)),
125            state: Arc::new(AtomicU8::new(AuthState::Unauthenticated.as_u8())),
126            state_notify: Arc::new(tokio::sync::Notify::new()),
127        }
128    }
129
130    /// Returns the current authentication state.
131    #[must_use]
132    pub fn auth_state(&self) -> AuthState {
133        AuthState::from_u8(self.state.load(Ordering::Acquire))
134    }
135
136    /// Returns whether the client is currently authenticated.
137    #[must_use]
138    pub fn is_authenticated(&self) -> bool {
139        self.auth_state() == AuthState::Authenticated
140    }
141
142    /// Clears the authentication state without affecting pending auth attempts.
143    ///
144    /// Call this when a live connection drops and reconnect may authenticate
145    /// again, so operations requiring authentication are properly guarded.
146    pub fn invalidate(&self) {
147        self.state
148            .store(AuthState::Unauthenticated.as_u8(), Ordering::Release);
149        self.state_notify.notify_waiters();
150    }
151
152    /// Begins a new authentication attempt.
153    ///
154    /// Returns a receiver that will be notified when authentication completes.
155    /// If a previous authentication attempt is still pending, it will be cancelled
156    /// with an error message indicating it was superseded.
157    ///
158    /// Transitions to `Unauthenticated` since a new attempt invalidates any
159    /// previous status.
160    #[allow(
161        clippy::must_use_candidate,
162        reason = "callers use this for side effects"
163    )]
164    pub fn begin(&self) -> AuthResultReceiver {
165        let (sender, receiver) = tokio::sync::oneshot::channel();
166        self.state
167            .store(AuthState::Unauthenticated.as_u8(), Ordering::Release);
168
169        if let Ok(mut guard) = self.tx.lock() {
170            if let Some(old) = guard.take() {
171                log::warn!("New authentication request superseding previous pending request");
172                let _ = old.send(Err("Authentication attempt superseded".to_string()));
173            } else {
174                log::debug!("Starting new authentication request");
175            }
176            *guard = Some(sender);
177        }
178
179        receiver
180    }
181
182    /// Marks the current authentication attempt as successful.
183    ///
184    /// Transitions to `Authenticated` and notifies any waiting receiver
185    /// with `Ok(())`. This should be called when the server sends a successful
186    /// authentication response.
187    ///
188    /// The state is always updated even if no receiver is waiting (e.g., after
189    /// a timeout), since the server has confirmed authentication.
190    pub fn succeed(&self) {
191        self.state
192            .store(AuthState::Authenticated.as_u8(), Ordering::Release);
193        self.state_notify.notify_waiters();
194
195        if let Ok(mut guard) = self.tx.lock()
196            && let Some(sender) = guard.take()
197        {
198            let _ = sender.send(Ok(()));
199        }
200    }
201
202    /// Marks the current authentication attempt as failed.
203    ///
204    /// Transitions to `Failed` and notifies any waiting receiver
205    /// with `Err(message)`. This should be called when the server sends an
206    /// authentication error response, or on terminal client shutdown.
207    ///
208    /// The state is always updated even if no receiver is waiting, since the
209    /// server has rejected authentication or future auth is impossible.
210    pub fn fail(&self, error: impl Into<String>) {
211        self.state
212            .store(AuthState::Failed.as_u8(), Ordering::Release);
213        self.state_notify.notify_waiters();
214        let message = error.into();
215
216        if let Ok(mut guard) = self.tx.lock()
217            && let Some(sender) = guard.take()
218        {
219            let _ = sender.send(Err(message));
220        }
221    }
222
223    /// Waits for the authentication result with a timeout.
224    ///
225    /// Returns `Ok(())` if authentication succeeds, or an error if it fails,
226    /// times out, or the channel is closed.
227    ///
228    /// # Type Parameters
229    ///
230    /// - `E`: Error type that implements `From<String>` for error message conversion
231    ///
232    /// # Errors
233    ///
234    /// Returns an error in the following cases:
235    /// - Authentication fails (server rejects credentials)
236    /// - Authentication times out (no response within timeout duration)
237    /// - Authentication channel closes unexpectedly
238    /// - Authentication attempt is superseded by a new attempt
239    pub async fn wait_for_result<E>(
240        &self,
241        timeout: Duration,
242        receiver: AuthResultReceiver,
243    ) -> Result<(), E>
244    where
245        E: From<String>,
246    {
247        match tokio::time::timeout(timeout, receiver).await {
248            Ok(Ok(Ok(()))) => Ok(()),
249            Ok(Ok(Err(msg))) => Err(E::from(msg)),
250            Ok(Err(_)) => Err(E::from("Authentication channel closed".to_string())),
251            Err(_) => {
252                // Don't clear the sender: a concurrent begin() may have replaced it,
253                // and guard.take() would cancel the newer sender. The next begin()
254                // call cleans up any stale sender.
255                Err(E::from("Authentication timed out".to_string()))
256            }
257        }
258    }
259
260    /// Waits for the tracker to enter the authenticated state.
261    ///
262    /// Returns `true` if authenticated within the timeout, `false` if the timeout
263    /// expires or authentication explicitly fails. Uses event-driven notification
264    /// from `succeed()` / `fail()` / `invalidate()` to avoid polling.
265    ///
266    /// Returns early with `false` when `fail()` is called (e.g., the exchange
267    /// rejects credentials), so callers are not blocked for the full timeout
268    /// on a definitive auth rejection.
269    ///
270    /// This is intended for callers on a separate task who need to gate operations
271    /// on authentication state (e.g., order sends that must wait for re-authentication
272    /// after a WebSocket reconnection).
273    pub async fn wait_for_authenticated(&self, timeout: Duration) -> bool {
274        if self.is_authenticated() {
275            return true;
276        }
277
278        tokio::time::timeout(timeout, async {
279            loop {
280                // Enable before the state check: an unpolled Notified is unregistered and misses notifies
281                let mut notified = pin!(self.state_notify.notified());
282                notified.as_mut().enable();
283
284                match self.auth_state() {
285                    AuthState::Authenticated => return true,
286                    AuthState::Failed => return false,
287                    AuthState::Unauthenticated => notified.await,
288                }
289            }
290        })
291        .await
292        .unwrap_or(false)
293    }
294}
295
296impl Default for AuthTracker {
297    fn default() -> Self {
298        Self::new()
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use std::{
305        sync::atomic::{AtomicBool, Ordering},
306        time::Duration,
307    };
308
309    use rstest::rstest;
310
311    use super::*;
312
313    #[derive(Debug, PartialEq)]
314    struct TestError(String);
315
316    impl From<String> for TestError {
317        fn from(msg: String) -> Self {
318            Self(msg)
319        }
320    }
321
322    #[rstest]
323    #[tokio::test]
324    async fn test_successful_authentication() {
325        let tracker = AuthTracker::new();
326        let rx = tracker.begin();
327
328        tracker.succeed();
329
330        let result: Result<(), TestError> =
331            tracker.wait_for_result(Duration::from_secs(1), rx).await;
332
333        assert!(result.is_ok());
334    }
335
336    #[rstest]
337    #[tokio::test]
338    async fn test_failed_authentication() {
339        let tracker = AuthTracker::new();
340        let rx = tracker.begin();
341
342        tracker.fail("Invalid credentials");
343
344        let result: Result<(), TestError> =
345            tracker.wait_for_result(Duration::from_secs(1), rx).await;
346
347        assert_eq!(
348            result.unwrap_err(),
349            TestError("Invalid credentials".to_string())
350        );
351    }
352
353    #[rstest]
354    #[tokio::test]
355    async fn test_authentication_timeout() {
356        let tracker = AuthTracker::new();
357        let rx = tracker.begin();
358
359        // Don't call succeed or fail - let it timeout
360
361        let result: Result<(), TestError> =
362            tracker.wait_for_result(Duration::from_millis(50), rx).await;
363
364        assert_eq!(
365            result.unwrap_err(),
366            TestError("Authentication timed out".to_string())
367        );
368    }
369
370    #[rstest]
371    #[tokio::test]
372    async fn test_begin_supersedes_previous_sender() {
373        let tracker = AuthTracker::new();
374
375        let first = tracker.begin();
376        let second = tracker.begin();
377
378        // First receiver should get superseded error
379        let result = first.await.expect("oneshot closed unexpectedly");
380        assert_eq!(result, Err("Authentication attempt superseded".to_string()));
381
382        // Second attempt should succeed
383        tracker.succeed();
384        let result: Result<(), TestError> = tracker
385            .wait_for_result(Duration::from_secs(1), second)
386            .await;
387
388        assert!(result.is_ok());
389    }
390
391    #[rstest]
392    #[tokio::test]
393    async fn test_succeed_without_pending_auth() {
394        let tracker = AuthTracker::new();
395
396        // Calling succeed without begin should not panic
397        tracker.succeed();
398    }
399
400    #[rstest]
401    #[tokio::test]
402    async fn test_fail_without_pending_auth() {
403        let tracker = AuthTracker::new();
404
405        // Calling fail without begin should not panic
406        tracker.fail("Some error");
407    }
408
409    #[rstest]
410    #[tokio::test]
411    async fn test_multiple_sequential_authentications() {
412        let tracker = AuthTracker::new();
413
414        // First auth succeeds
415        let rx1 = tracker.begin();
416        tracker.succeed();
417        let result1: Result<(), TestError> =
418            tracker.wait_for_result(Duration::from_secs(1), rx1).await;
419        assert!(result1.is_ok());
420
421        // Second auth fails
422        let rx2 = tracker.begin();
423        tracker.fail("Credentials expired");
424        let result2: Result<(), TestError> =
425            tracker.wait_for_result(Duration::from_secs(1), rx2).await;
426        assert_eq!(
427            result2.unwrap_err(),
428            TestError("Credentials expired".to_string())
429        );
430
431        // Third auth succeeds
432        let rx3 = tracker.begin();
433        tracker.succeed();
434        let result3: Result<(), TestError> =
435            tracker.wait_for_result(Duration::from_secs(1), rx3).await;
436        assert!(result3.is_ok());
437    }
438
439    #[rstest]
440    #[tokio::test]
441    async fn test_channel_closed_before_result() {
442        let tracker = AuthTracker::new();
443        let rx = tracker.begin();
444
445        // Drop the tracker's sender by starting a new auth
446        tracker.begin();
447
448        // Original receiver should get channel closed error
449        let result: Result<(), TestError> =
450            tracker.wait_for_result(Duration::from_secs(1), rx).await;
451
452        assert_eq!(
453            result.unwrap_err(),
454            TestError("Authentication attempt superseded".to_string())
455        );
456    }
457
458    #[rstest]
459    #[tokio::test]
460    async fn test_concurrent_auth_attempts() {
461        let tracker = Arc::new(AuthTracker::new());
462        let mut handles = vec![];
463
464        // Spawn 10 concurrent auth attempts
465        for i in 0..10 {
466            let tracker_clone = Arc::clone(&tracker);
467            let handle = tokio::spawn(async move {
468                let rx = tracker_clone.begin();
469
470                // Only the last one should succeed
471                if i == 9 {
472                    tokio::time::sleep(Duration::from_millis(10)).await;
473                    tracker_clone.succeed();
474                }
475
476                let result: Result<(), TestError> = tracker_clone
477                    .wait_for_result(Duration::from_secs(1), rx)
478                    .await;
479
480                (i, result)
481            });
482            handles.push(handle);
483        }
484
485        let mut successes = 0;
486        let mut superseded = 0;
487
488        for handle in handles {
489            let (i, result) = handle.await.unwrap();
490            match result {
491                Ok(()) => {
492                    // Only task 9 should succeed
493                    assert_eq!(i, 9);
494                    successes += 1;
495                }
496                Err(TestError(msg)) if msg.contains("superseded") => {
497                    superseded += 1;
498                }
499                Err(e) => panic!("Unexpected error: {e:?}"),
500            }
501        }
502
503        assert_eq!(successes, 1);
504        assert_eq!(superseded, 9);
505    }
506
507    #[rstest]
508    fn test_default_trait() {
509        let _tracker = AuthTracker::default();
510    }
511
512    #[rstest]
513    #[tokio::test]
514    async fn test_clone_trait() {
515        let tracker = AuthTracker::new();
516        let cloned = tracker.clone();
517
518        // Verify cloned instance shares state with original (Arc behavior)
519        let rx = tracker.begin();
520        cloned.succeed(); // Succeed via clone affects original
521        let result: Result<(), TestError> =
522            tracker.wait_for_result(Duration::from_secs(1), rx).await;
523        assert!(result.is_ok());
524    }
525
526    #[rstest]
527    fn test_debug_trait() {
528        let tracker = AuthTracker::new();
529        let debug_str = format!("{tracker:?}");
530        assert!(debug_str.contains("AuthTracker"));
531    }
532
533    #[rstest]
534    #[tokio::test]
535    async fn test_timeout_clears_sender() {
536        let tracker = AuthTracker::new();
537
538        // Start auth that will timeout
539        let rx1 = tracker.begin();
540        let result1: Result<(), TestError> = tracker
541            .wait_for_result(Duration::from_millis(50), rx1)
542            .await;
543        assert_eq!(
544            result1.unwrap_err(),
545            TestError("Authentication timed out".to_string())
546        );
547
548        // Verify sender was cleared - new auth should work
549        let rx2 = tracker.begin();
550        tracker.succeed();
551        let result2: Result<(), TestError> =
552            tracker.wait_for_result(Duration::from_secs(1), rx2).await;
553        assert!(result2.is_ok());
554    }
555
556    #[rstest]
557    #[tokio::test]
558    async fn test_fail_clears_sender() {
559        let tracker = AuthTracker::new();
560
561        // Auth fails
562        let rx1 = tracker.begin();
563        tracker.fail("Bad credentials");
564        let result1: Result<(), TestError> =
565            tracker.wait_for_result(Duration::from_secs(1), rx1).await;
566        assert!(result1.is_err());
567
568        // Verify sender was cleared - new auth should work
569        let rx2 = tracker.begin();
570        tracker.succeed();
571        let result2: Result<(), TestError> =
572            tracker.wait_for_result(Duration::from_secs(1), rx2).await;
573        assert!(result2.is_ok());
574    }
575
576    #[rstest]
577    #[tokio::test]
578    async fn test_succeed_clears_sender() {
579        let tracker = AuthTracker::new();
580
581        // Auth succeeds
582        let rx1 = tracker.begin();
583        tracker.succeed();
584        let result1: Result<(), TestError> =
585            tracker.wait_for_result(Duration::from_secs(1), rx1).await;
586        assert!(result1.is_ok());
587
588        // Verify sender was cleared - new auth should work
589        let rx2 = tracker.begin();
590        tracker.succeed();
591        let result2: Result<(), TestError> =
592            tracker.wait_for_result(Duration::from_secs(1), rx2).await;
593        assert!(result2.is_ok());
594    }
595
596    #[rstest]
597    #[tokio::test]
598    async fn test_rapid_begin_succeed_cycles() {
599        let tracker = AuthTracker::new();
600
601        // Rapidly cycle through auth attempts
602        for _ in 0..100 {
603            let rx = tracker.begin();
604            tracker.succeed();
605            let result: Result<(), TestError> =
606                tracker.wait_for_result(Duration::from_secs(1), rx).await;
607            assert!(result.is_ok());
608        }
609    }
610
611    #[rstest]
612    #[tokio::test]
613    async fn test_double_succeed_is_safe() {
614        let tracker = AuthTracker::new();
615        let rx = tracker.begin();
616
617        // Call succeed twice
618        tracker.succeed();
619        tracker.succeed(); // Second call should be no-op
620
621        let result: Result<(), TestError> =
622            tracker.wait_for_result(Duration::from_secs(1), rx).await;
623        assert!(result.is_ok());
624    }
625
626    #[rstest]
627    #[tokio::test]
628    async fn test_double_fail_is_safe() {
629        let tracker = AuthTracker::new();
630        let rx = tracker.begin();
631
632        // Call fail twice
633        tracker.fail("Error 1");
634        tracker.fail("Error 2"); // Second call should be no-op
635
636        let result: Result<(), TestError> =
637            tracker.wait_for_result(Duration::from_secs(1), rx).await;
638        assert_eq!(
639            result.unwrap_err(),
640            TestError("Error 1".to_string()) // Should be first error
641        );
642    }
643
644    #[rstest]
645    #[tokio::test]
646    async fn test_succeed_after_fail_is_ignored() {
647        let tracker = AuthTracker::new();
648        let rx = tracker.begin();
649
650        tracker.fail("Auth failed");
651        tracker.succeed(); // This should be no-op
652
653        let result: Result<(), TestError> =
654            tracker.wait_for_result(Duration::from_secs(1), rx).await;
655        assert!(result.is_err()); // Should still be error
656    }
657
658    #[rstest]
659    #[tokio::test]
660    async fn test_fail_after_succeed_is_ignored() {
661        let tracker = AuthTracker::new();
662        let rx = tracker.begin();
663
664        tracker.succeed();
665        tracker.fail("Auth failed"); // This should be no-op
666
667        let result: Result<(), TestError> =
668            tracker.wait_for_result(Duration::from_secs(1), rx).await;
669        assert!(result.is_ok()); // Should still be success
670    }
671
672    /// Simulates a reconnect flow where authentication must complete before resubscription.
673    ///
674    /// This is an integration-style test that verifies:
675    /// 1. On reconnect, authentication starts first
676    /// 2. Subscription logic waits for auth to complete
677    /// 3. Subscriptions only proceed after successful auth
678    #[rstest]
679    #[tokio::test]
680    async fn test_reconnect_flow_waits_for_auth() {
681        let tracker = Arc::new(AuthTracker::new());
682        let subscribed = Arc::new(tokio::sync::Notify::new());
683        let auth_completed = Arc::new(tokio::sync::Notify::new());
684
685        // Simulate reconnect handler
686        let tracker_reconnect = Arc::clone(&tracker);
687        let subscribed_reconnect = Arc::clone(&subscribed);
688        let auth_completed_reconnect = Arc::clone(&auth_completed);
689
690        let reconnect_task = tokio::spawn(async move {
691            // Step 1: Begin authentication
692            let rx = tracker_reconnect.begin();
693
694            // Step 2: Spawn resubscription task that waits for auth
695            let tracker_resub = Arc::clone(&tracker_reconnect);
696            let subscribed_resub = Arc::clone(&subscribed_reconnect);
697            let auth_completed_resub = Arc::clone(&auth_completed_reconnect);
698
699            let resub_task = tokio::spawn(async move {
700                // Wait for auth to complete
701                let result: Result<(), TestError> = tracker_resub
702                    .wait_for_result(Duration::from_secs(5), rx)
703                    .await;
704
705                if result.is_ok() {
706                    auth_completed_resub.notify_one();
707                    // Simulate resubscription
708                    tokio::time::sleep(Duration::from_millis(10)).await;
709                    subscribed_resub.notify_one();
710                }
711            });
712
713            resub_task.await.unwrap();
714        });
715
716        // Simulate server auth response after delay
717        tokio::time::sleep(Duration::from_millis(100)).await;
718        tracker.succeed();
719
720        // Wait for reconnect flow to complete
721        reconnect_task.await.unwrap();
722
723        // Verify auth completed before subscription
724        tokio::select! {
725            () = auth_completed.notified() => {
726                // Good - auth completed
727            }
728            () = tokio::time::sleep(Duration::from_secs(1)) => {
729                panic!("Auth never completed");
730            }
731        }
732
733        // Verify subscription completed
734        tokio::select! {
735            () = subscribed.notified() => {
736                // Good - subscribed
737            }
738            () = tokio::time::sleep(Duration::from_secs(1)) => {
739                panic!("Subscription never completed");
740            }
741        }
742    }
743
744    /// Verifies that failed authentication prevents resubscription in reconnect flow.
745    #[rstest]
746    #[tokio::test]
747    async fn test_reconnect_flow_blocks_on_auth_failure() {
748        let tracker = Arc::new(AuthTracker::new());
749        let subscribed = Arc::new(AtomicBool::new(false));
750
751        let tracker_reconnect = Arc::clone(&tracker);
752        let subscribed_reconnect = Arc::clone(&subscribed);
753
754        let reconnect_task = tokio::spawn(async move {
755            let rx = tracker_reconnect.begin();
756
757            // Spawn resubscription task that waits for auth
758            let tracker_resub = Arc::clone(&tracker_reconnect);
759            let subscribed_resub = Arc::clone(&subscribed_reconnect);
760
761            let resub_task = tokio::spawn(async move {
762                let result: Result<(), TestError> = tracker_resub
763                    .wait_for_result(Duration::from_secs(5), rx)
764                    .await;
765
766                // Only subscribe if auth succeeds
767                if result.is_ok() {
768                    subscribed_resub.store(true, Ordering::Relaxed);
769                }
770            });
771
772            resub_task.await.unwrap();
773        });
774
775        // Simulate server auth failure
776        tokio::time::sleep(Duration::from_millis(50)).await;
777        tracker.fail("Invalid credentials");
778
779        // Wait for reconnect flow to complete
780        reconnect_task.await.unwrap();
781
782        // Verify subscription never happened
783        tokio::time::sleep(Duration::from_millis(100)).await;
784        assert!(!subscribed.load(Ordering::Relaxed));
785    }
786
787    /// Tests state machine transitions exhaustively.
788    #[rstest]
789    #[tokio::test]
790    async fn test_state_machine_transitions() {
791        let tracker = AuthTracker::new();
792
793        // Transition 1: Initial -> Pending (begin)
794        let rx1 = tracker.begin();
795
796        // Transition 2: Pending -> Success (succeed)
797        tracker.succeed();
798        let result1: Result<(), TestError> =
799            tracker.wait_for_result(Duration::from_secs(1), rx1).await;
800        assert!(result1.is_ok());
801
802        // Transition 3: Success -> Pending (begin again)
803        let rx2 = tracker.begin();
804
805        // Transition 4: Pending -> Failure (fail)
806        tracker.fail("Error");
807        let result2: Result<(), TestError> =
808            tracker.wait_for_result(Duration::from_secs(1), rx2).await;
809        assert!(result2.is_err());
810
811        // Transition 5: Failure -> Pending (begin again)
812        let rx3 = tracker.begin();
813
814        // Transition 6: Pending -> Timeout
815        let result3: Result<(), TestError> = tracker
816            .wait_for_result(Duration::from_millis(50), rx3)
817            .await;
818        assert_eq!(
819            result3.unwrap_err(),
820            TestError("Authentication timed out".to_string())
821        );
822
823        // Transition 7: Timeout -> Pending (begin again)
824        let rx4 = tracker.begin();
825
826        // Transition 8: Pending -> Superseded (begin interrupts)
827        let rx5 = tracker.begin();
828        let result4: Result<(), TestError> =
829            tracker.wait_for_result(Duration::from_secs(1), rx4).await;
830        assert_eq!(
831            result4.unwrap_err(),
832            TestError("Authentication attempt superseded".to_string())
833        );
834
835        // Final success to clean up
836        tracker.succeed();
837        let result5: Result<(), TestError> =
838            tracker.wait_for_result(Duration::from_secs(1), rx5).await;
839        assert!(result5.is_ok());
840    }
841
842    /// Verifies no memory leaks from orphaned senders.
843    #[rstest]
844    #[tokio::test]
845    async fn test_no_sender_leaks() {
846        let tracker = AuthTracker::new();
847
848        for _ in 0..100 {
849            let rx = tracker.begin();
850            let _result: Result<(), TestError> =
851                tracker.wait_for_result(Duration::from_millis(1), rx).await;
852        }
853
854        let rx = tracker.begin();
855        tracker.succeed();
856        let result: Result<(), TestError> =
857            tracker.wait_for_result(Duration::from_secs(1), rx).await;
858        assert!(result.is_ok());
859    }
860
861    /// Tests concurrent success/fail calls don't cause panics.
862    #[rstest]
863    #[tokio::test]
864    async fn test_concurrent_succeed_fail_calls() {
865        let tracker = Arc::new(AuthTracker::new());
866        let rx = tracker.begin();
867
868        let mut handles = vec![];
869
870        // Spawn many tasks trying to succeed
871        for _ in 0..50 {
872            let tracker_clone = Arc::clone(&tracker);
873            handles.push(tokio::spawn(async move {
874                tracker_clone.succeed();
875            }));
876        }
877
878        // Spawn many tasks trying to fail
879        for _ in 0..50 {
880            let tracker_clone = Arc::clone(&tracker);
881            handles.push(tokio::spawn(async move {
882                tracker_clone.fail("Error");
883            }));
884        }
885
886        // Wait for all tasks
887        for handle in handles {
888            handle.await.unwrap();
889        }
890
891        // Should get either success or failure, but not panic
892        let result: Result<(), TestError> =
893            tracker.wait_for_result(Duration::from_secs(1), rx).await;
894        // Don't care which outcome, just that it doesn't panic
895        let _ = result;
896    }
897
898    #[rstest]
899    fn test_is_authenticated_initial_state() {
900        let tracker = AuthTracker::new();
901        assert!(!tracker.is_authenticated());
902    }
903
904    #[rstest]
905    #[tokio::test]
906    async fn test_is_authenticated_after_succeed() {
907        let tracker = AuthTracker::new();
908        assert!(!tracker.is_authenticated());
909
910        let _rx = tracker.begin();
911        assert!(!tracker.is_authenticated());
912
913        tracker.succeed();
914        assert!(tracker.is_authenticated());
915    }
916
917    #[rstest]
918    #[tokio::test]
919    async fn test_is_authenticated_after_fail() {
920        let tracker = AuthTracker::new();
921        let _rx = tracker.begin();
922        tracker.fail("error");
923        assert!(!tracker.is_authenticated());
924    }
925
926    #[rstest]
927    #[tokio::test]
928    async fn test_invalidate_clears_auth_state() {
929        let tracker = AuthTracker::new();
930        let _rx = tracker.begin();
931        tracker.succeed();
932        assert!(tracker.is_authenticated());
933
934        tracker.invalidate();
935        assert!(!tracker.is_authenticated());
936    }
937
938    #[rstest]
939    #[tokio::test]
940    async fn test_begin_clears_auth_state() {
941        let tracker = AuthTracker::new();
942        let _rx1 = tracker.begin();
943        tracker.succeed();
944        assert!(tracker.is_authenticated());
945
946        let _rx2 = tracker.begin();
947        assert!(!tracker.is_authenticated());
948    }
949
950    #[rstest]
951    fn test_is_authenticated_shared_across_clones() {
952        let tracker = AuthTracker::new();
953        let cloned = tracker.clone();
954
955        let _rx = tracker.begin();
956        tracker.succeed();
957
958        assert!(cloned.is_authenticated());
959    }
960
961    #[rstest]
962    fn test_invalidate_shared_across_clones() {
963        let tracker = AuthTracker::new();
964        let cloned = tracker.clone();
965
966        let _rx = tracker.begin();
967        tracker.succeed();
968        assert!(tracker.is_authenticated());
969
970        cloned.invalidate();
971        assert!(!tracker.is_authenticated());
972    }
973
974    #[rstest]
975    fn test_succeed_without_begin_still_updates_auth_state() {
976        let tracker = AuthTracker::new();
977        assert!(!tracker.is_authenticated());
978
979        // State updates even without begin() to handle late responses after timeout
980        tracker.succeed();
981        assert!(tracker.is_authenticated());
982    }
983
984    #[rstest]
985    fn test_fail_without_begin_still_updates_auth_state() {
986        let tracker = AuthTracker::new();
987        tracker.succeed();
988        assert!(tracker.is_authenticated());
989
990        // State updates even without begin() to handle late responses
991        tracker.fail("error");
992        assert!(!tracker.is_authenticated());
993    }
994
995    #[rstest]
996    #[tokio::test]
997    async fn test_auth_state_false_after_timeout_until_late_response() {
998        let tracker = AuthTracker::new();
999        let rx = tracker.begin();
1000        assert!(!tracker.is_authenticated());
1001
1002        let result: Result<(), TestError> =
1003            tracker.wait_for_result(Duration::from_millis(10), rx).await;
1004
1005        assert!(result.is_err());
1006        assert!(!tracker.is_authenticated());
1007
1008        // Late response after timeout still updates state
1009        tracker.succeed();
1010        assert!(tracker.is_authenticated());
1011    }
1012
1013    #[rstest]
1014    #[tokio::test]
1015    async fn test_wait_for_authenticated_already_authenticated() {
1016        let tracker = AuthTracker::new();
1017        let _rx = tracker.begin();
1018        tracker.succeed();
1019
1020        assert!(
1021            tracker
1022                .wait_for_authenticated(Duration::from_millis(50))
1023                .await
1024        );
1025    }
1026
1027    #[rstest]
1028    #[tokio::test]
1029    async fn test_wait_for_authenticated_succeeds_after_delay() {
1030        let tracker = AuthTracker::new();
1031        let _rx = tracker.begin();
1032
1033        let tracker_clone = tracker.clone();
1034
1035        tokio::spawn(async move {
1036            tokio::time::sleep(Duration::from_millis(50)).await;
1037            tracker_clone.succeed();
1038        });
1039
1040        assert!(tracker.wait_for_authenticated(Duration::from_secs(1)).await);
1041    }
1042
1043    #[rstest]
1044    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1045    async fn test_wait_for_authenticated_no_lost_wakeup_under_race() {
1046        // Regression: a succeed() landing between the waiter's state check and
1047        // its first poll of Notified must not be lost. Notified only registers
1048        // with the Notify once polled or enabled, so without enable() this
1049        // stalls for the full timeout and returns false on some iterations.
1050        for _ in 0..200 {
1051            let tracker = AuthTracker::new();
1052            let _rx = tracker.begin();
1053
1054            let succeeder = tracker.clone();
1055            let handle = std::thread::spawn(move || succeeder.succeed());
1056
1057            assert!(
1058                tracker
1059                    .wait_for_authenticated(Duration::from_millis(500))
1060                    .await,
1061                "wakeup lost despite successful authentication"
1062            );
1063            handle.join().unwrap();
1064        }
1065    }
1066
1067    #[rstest]
1068    #[tokio::test]
1069    async fn test_wait_for_authenticated_returns_false_on_failure() {
1070        let tracker = AuthTracker::new();
1071        let _rx = tracker.begin();
1072
1073        let tracker_clone = tracker.clone();
1074
1075        tokio::spawn(async move {
1076            tokio::time::sleep(Duration::from_millis(50)).await;
1077            tracker_clone.fail("rejected");
1078        });
1079
1080        let start = tokio::time::Instant::now();
1081        let result = tracker.wait_for_authenticated(Duration::from_secs(5)).await;
1082        let elapsed = start.elapsed();
1083
1084        assert!(!result);
1085        assert!(elapsed < Duration::from_secs(1));
1086    }
1087
1088    #[rstest]
1089    #[tokio::test]
1090    async fn test_wait_for_authenticated_times_out() {
1091        let tracker = AuthTracker::new();
1092        let _rx = tracker.begin();
1093
1094        assert!(
1095            !tracker
1096                .wait_for_authenticated(Duration::from_millis(50))
1097                .await
1098        );
1099    }
1100
1101    #[rstest]
1102    #[tokio::test]
1103    async fn test_wait_for_authenticated_begin_clears_failed() {
1104        let tracker = AuthTracker::new();
1105        let _rx = tracker.begin();
1106        tracker.fail("first attempt");
1107
1108        assert!(
1109            !tracker
1110                .wait_for_authenticated(Duration::from_millis(10))
1111                .await
1112        );
1113
1114        // begin() clears the failed flag, allowing a fresh wait
1115        let _rx = tracker.begin();
1116
1117        let tracker_clone = tracker.clone();
1118
1119        tokio::spawn(async move {
1120            tokio::time::sleep(Duration::from_millis(50)).await;
1121            tracker_clone.succeed();
1122        });
1123
1124        assert!(tracker.wait_for_authenticated(Duration::from_secs(1)).await);
1125    }
1126
1127    #[rstest]
1128    #[tokio::test]
1129    async fn test_wait_for_authenticated_invalidate_does_not_return_false() {
1130        let tracker = AuthTracker::new();
1131        let _rx = tracker.begin();
1132
1133        let tracker_clone = tracker.clone();
1134
1135        tokio::spawn(async move {
1136            // invalidate wakes the loop but should not cause early false return
1137            tokio::time::sleep(Duration::from_millis(20)).await;
1138            tracker_clone.invalidate();
1139            // then succeed shortly after
1140            tokio::time::sleep(Duration::from_millis(20)).await;
1141            tracker_clone.succeed();
1142        });
1143
1144        assert!(tracker.wait_for_authenticated(Duration::from_secs(1)).await);
1145    }
1146
1147    #[rstest]
1148    #[tokio::test]
1149    async fn test_wait_for_authenticated_concurrent_waiters() {
1150        let tracker = Arc::new(AuthTracker::new());
1151        let _rx = tracker.begin();
1152
1153        let mut handles = vec![];
1154
1155        for _ in 0..10 {
1156            let t = Arc::clone(&tracker);
1157            handles.push(tokio::spawn(async move {
1158                t.wait_for_authenticated(Duration::from_secs(1)).await
1159            }));
1160        }
1161
1162        tokio::time::sleep(Duration::from_millis(50)).await;
1163        tracker.succeed();
1164
1165        for handle in handles {
1166            assert!(handle.await.unwrap());
1167        }
1168    }
1169
1170    #[rstest]
1171    #[tokio::test]
1172    async fn test_wait_for_authenticated_not_authenticated_initially() {
1173        let tracker = AuthTracker::new();
1174
1175        // Not authenticated, no begin() called, no failed flag set
1176        // Should time out
1177        assert!(
1178            !tracker
1179                .wait_for_authenticated(Duration::from_millis(50))
1180                .await
1181        );
1182    }
1183}
1184
1185#[cfg(test)]
1186mod proptest_tests {
1187    use std::{sync::Arc, time::Duration};
1188
1189    use proptest::prelude::*;
1190    use rstest::rstest;
1191
1192    use super::*;
1193
1194    const AUTH_FAILED: &str = "model auth failed";
1195    const AUTH_SUPERSEDED: &str = "Authentication attempt superseded";
1196
1197    #[derive(Debug, Clone)]
1198    enum AuthTraceOp {
1199        Begin,
1200        Succeed,
1201        Fail,
1202        Invalidate,
1203        WaitForAuthenticated,
1204    }
1205
1206    #[derive(Debug, Clone, PartialEq, Eq)]
1207    enum ExpectedAuthResult {
1208        Success,
1209        Failed(&'static str),
1210    }
1211
1212    #[derive(Debug)]
1213    struct AuthTraceModel {
1214        state: AuthState,
1215        pending_receiver: Option<usize>,
1216        expected_results: Vec<Option<ExpectedAuthResult>>,
1217    }
1218
1219    impl AuthTraceModel {
1220        fn new() -> Self {
1221            Self {
1222                state: AuthState::Unauthenticated,
1223                pending_receiver: None,
1224                expected_results: Vec::new(),
1225            }
1226        }
1227
1228        fn begin(&mut self) {
1229            if let Some(receiver_index) = self.pending_receiver.take() {
1230                self.expected_results[receiver_index] =
1231                    Some(ExpectedAuthResult::Failed(AUTH_SUPERSEDED));
1232            }
1233
1234            self.pending_receiver = Some(self.expected_results.len());
1235            self.expected_results.push(None);
1236            self.state = AuthState::Unauthenticated;
1237        }
1238
1239        fn succeed(&mut self) {
1240            self.state = AuthState::Authenticated;
1241
1242            if let Some(receiver_index) = self.pending_receiver.take() {
1243                self.expected_results[receiver_index] = Some(ExpectedAuthResult::Success);
1244            }
1245        }
1246
1247        fn fail(&mut self) {
1248            self.state = AuthState::Failed;
1249
1250            if let Some(receiver_index) = self.pending_receiver.take() {
1251                self.expected_results[receiver_index] =
1252                    Some(ExpectedAuthResult::Failed(AUTH_FAILED));
1253            }
1254        }
1255
1256        fn invalidate(&mut self) {
1257            self.state = AuthState::Unauthenticated;
1258        }
1259    }
1260
1261    fn auth_trace_op_strategy() -> impl Strategy<Value = AuthTraceOp> {
1262        prop_oneof![
1263            Just(AuthTraceOp::Begin),
1264            Just(AuthTraceOp::Succeed),
1265            Just(AuthTraceOp::Fail),
1266            Just(AuthTraceOp::Invalidate),
1267            Just(AuthTraceOp::WaitForAuthenticated),
1268        ]
1269    }
1270
1271    fn assert_auth_receivers_match_model(
1272        receivers: &mut [Option<AuthResultReceiver>],
1273        model: &AuthTraceModel,
1274        step: usize,
1275    ) -> Result<(), TestCaseError> {
1276        for (receiver_index, receiver_slot) in receivers.iter_mut().enumerate() {
1277            let Some(receiver) = receiver_slot.as_mut() else {
1278                continue;
1279            };
1280
1281            let mut clear_receiver = false;
1282
1283            match &model.expected_results[receiver_index] {
1284                Some(ExpectedAuthResult::Success) => {
1285                    match receiver.try_recv() {
1286                        Ok(Ok(())) => {}
1287                        actual => prop_assert!(
1288                            false,
1289                            "receiver {} should succeed at step {}, was {:?}",
1290                            receiver_index,
1291                            step,
1292                            actual
1293                        ),
1294                    }
1295                    clear_receiver = true;
1296                }
1297                Some(ExpectedAuthResult::Failed(expected)) => {
1298                    match receiver.try_recv() {
1299                        Ok(Err(actual)) => prop_assert_eq!(
1300                            actual,
1301                            *expected,
1302                            "receiver {} should fail at step {}",
1303                            receiver_index,
1304                            step
1305                        ),
1306                        actual => prop_assert!(
1307                            false,
1308                            "receiver {} should fail at step {}, was {:?}",
1309                            receiver_index,
1310                            step,
1311                            actual
1312                        ),
1313                    }
1314                    clear_receiver = true;
1315                }
1316                None => {
1317                    prop_assert_eq!(
1318                        receiver.try_recv(),
1319                        Err(tokio::sync::oneshot::error::TryRecvError::Empty),
1320                        "receiver {} should stay pending at step {}",
1321                        receiver_index,
1322                        step
1323                    );
1324                }
1325            }
1326
1327            if clear_receiver {
1328                *receiver_slot = None;
1329            }
1330        }
1331
1332        Ok(())
1333    }
1334
1335    proptest! {
1336        #![proptest_config(ProptestConfig::with_cases(256))]
1337
1338        /// Property: auth attempt traces match the cycle model for success,
1339        /// failure, superseded stale receivers, invalidation, and auth waits.
1340        #[rstest]
1341        fn test_auth_tracker_trace_matches_cycle_model(
1342            ops in proptest::collection::vec(auth_trace_op_strategy(), 1..80)
1343        ) {
1344            let runtime = tokio::runtime::Builder::new_current_thread()
1345                .enable_time()
1346                .build()
1347                .unwrap();
1348            let tracker = AuthTracker::new();
1349            let mut model = AuthTraceModel::new();
1350            let mut receivers: Vec<Option<AuthResultReceiver>> = Vec::new();
1351
1352            for (step, op) in ops.iter().enumerate() {
1353                match op {
1354                    AuthTraceOp::Begin => {
1355                        receivers.push(Some(tracker.begin()));
1356                        model.begin();
1357                    }
1358                    AuthTraceOp::Succeed => {
1359                        tracker.succeed();
1360                        model.succeed();
1361                    }
1362                    AuthTraceOp::Fail => {
1363                        tracker.fail(AUTH_FAILED);
1364                        model.fail();
1365                    }
1366                    AuthTraceOp::Invalidate => {
1367                        tracker.invalidate();
1368                        model.invalidate();
1369                    }
1370                    AuthTraceOp::WaitForAuthenticated => {
1371                        let actual = runtime
1372                            .block_on(tracker.wait_for_authenticated(Duration::from_millis(0)));
1373                        let expected = model.state == AuthState::Authenticated;
1374                        prop_assert_eq!(
1375                            actual,
1376                            expected,
1377                            "wait_for_authenticated mismatch at step {}, op {:?}",
1378                            step,
1379                            op
1380                        );
1381                    }
1382                }
1383
1384                prop_assert_eq!(
1385                    tracker.auth_state(),
1386                    model.state,
1387                    "auth state mismatch at step {}, op {:?}",
1388                    step,
1389                    op
1390                );
1391                assert_auth_receivers_match_model(&mut receivers, &model, step)?;
1392            }
1393        }
1394
1395        /// Verifies that any sequence of begin/succeed/fail/invalidate calls
1396        /// leaves the tracker in a consistent state where `is_authenticated`
1397        /// agrees with the last state-setting call.
1398        #[rstest]
1399        fn test_state_consistency_after_random_operations(
1400            ops in proptest::collection::vec(0u8..4, 1..50)
1401        ) {
1402            let tracker = AuthTracker::new();
1403            let mut expected_auth = false;
1404
1405            for op in &ops {
1406                match op {
1407                    0 => {
1408                        let _rx = tracker.begin();
1409                        expected_auth = false;
1410                    }
1411                    1 => {
1412                        tracker.succeed();
1413                        expected_auth = true;
1414                    }
1415                    2 => {
1416                        tracker.fail("test");
1417                        expected_auth = false;
1418                    }
1419                    3 => {
1420                        tracker.invalidate();
1421                        expected_auth = false;
1422                    }
1423                    _ => unreachable!(),
1424                }
1425            }
1426
1427            prop_assert_eq!(tracker.is_authenticated(), expected_auth);
1428        }
1429
1430        /// Verifies that begin() always clears the failed flag regardless of
1431        /// prior state, so a new auth attempt starts clean.
1432        #[rstest]
1433        fn test_begin_always_clears_failed(
1434            prior_ops in proptest::collection::vec(0u8..4, 0..20)
1435        ) {
1436            let tracker = AuthTracker::new();
1437
1438            for op in &prior_ops {
1439                match op {
1440                    0 => { let _rx = tracker.begin(); }
1441                    1 => tracker.succeed(),
1442                    2 => tracker.fail("test"),
1443                    3 => tracker.invalidate(),
1444                    _ => unreachable!(),
1445                }
1446            }
1447
1448            let _rx = tracker.begin();
1449            // After begin(), state is Unauthenticated
1450            prop_assert_eq!(tracker.auth_state(), AuthState::Unauthenticated);
1451        }
1452
1453        /// Verifies that succeed() always transitions to Authenticated,
1454        /// regardless of prior state.
1455        #[rstest]
1456        fn test_succeed_always_sets_authenticated(
1457            prior_ops in proptest::collection::vec(0u8..4, 0..20)
1458        ) {
1459            let tracker = AuthTracker::new();
1460
1461            for op in &prior_ops {
1462                match op {
1463                    0 => { let _rx = tracker.begin(); }
1464                    1 => tracker.succeed(),
1465                    2 => tracker.fail("test"),
1466                    3 => tracker.invalidate(),
1467                    _ => unreachable!(),
1468                }
1469            }
1470
1471            tracker.succeed();
1472            prop_assert_eq!(tracker.auth_state(), AuthState::Authenticated);
1473        }
1474    }
1475
1476    /// Verifies that `wait_for_authenticated` returns within a bounded time
1477    /// when `succeed()` or `fail()` is called, regardless of the timeout value.
1478    #[rstest]
1479    #[tokio::test]
1480    async fn test_wait_responds_within_bounded_time() {
1481        for auth_result in [true, false] {
1482            let tracker = Arc::new(AuthTracker::new());
1483            let _rx = tracker.begin();
1484
1485            let tracker_clone = Arc::clone(&tracker);
1486
1487            tokio::spawn(async move {
1488                tokio::time::sleep(Duration::from_millis(30)).await;
1489
1490                if auth_result {
1491                    tracker_clone.succeed();
1492                } else {
1493                    tracker_clone.fail("rejected");
1494                }
1495            });
1496
1497            let start = tokio::time::Instant::now();
1498            let result = tracker
1499                .wait_for_authenticated(Duration::from_secs(10))
1500                .await;
1501            let elapsed = start.elapsed();
1502
1503            assert_eq!(result, auth_result);
1504            assert!(
1505                elapsed < Duration::from_millis(500),
1506                "wait_for_authenticated took {elapsed:?} for auth_result={auth_result}"
1507            );
1508        }
1509    }
1510}