@@ -22,7 +22,7 @@ const testReactClient = (address: string) =>
22
22
// https://linear.app/convex/issue/ENG-7052/re-enable-auth-websocket-client-tests
23
23
24
24
// On Linux these can retry forever due to EADDRINUSE so run then sequentially.
25
- describe . sequential . skip ( "auth websocket tests" , ( ) => {
25
+ describe . sequential ( "auth websocket tests" , ( ) => {
26
26
// This is the path usually taken on page load after a user logged in,
27
27
// with a constant token provider.
28
28
test ( "Authenticate via valid static token" , async ( ) => {
@@ -357,6 +357,8 @@ describe.sequential.skip("auth websocket tests", () => {
357
357
} ) ;
358
358
} ) ;
359
359
360
+ // This is a race condition where a delayed auth error from a non-auth message
361
+ // comes back while the client is waiting for server validation of the new token.
360
362
test ( "Client ignores non-auth responses for token validation" , async ( ) => {
361
363
await withInMemoryWebSocket ( async ( { address, receive, send } ) => {
362
364
const client = testReactClient ( address ) ;
@@ -390,9 +392,8 @@ describe.sequential.skip("auth websocket tests", () => {
390
392
391
393
expect ( ( await receive ( ) ) . type ) . toEqual ( "Authenticate" ) ;
392
394
393
- // In this race condition, a delayed auth error from a non-auth message
394
- // comes back while the client is waiting for server validation of
395
- // the new token.
395
+ // This auth error text is specific to query/mutation/action related auth
396
+ // errors, which should be ignored for token validation.
396
397
send ( {
397
398
type : "AuthError" ,
398
399
error : "Convex token identity expired" ,
@@ -429,20 +430,27 @@ describe.sequential.skip("auth websocket tests", () => {
429
430
} ) ;
430
431
} ) ;
431
432
433
+ // This is a race condition where a connection stopped by reauthentication
434
+ // never restarts due to reauthentication exiting early. This happens when
435
+ // an additional refetch begins while reauthentication is still running,
436
+ // such as with a scheduled refetch.
432
437
test ( "Client maintains connection when refetch occurs during reauth attempt" , async ( ) => {
433
438
await withInMemoryWebSocket ( async ( { address, receive, send, close } ) => {
434
439
vi . useFakeTimers ( ) ;
435
440
const client = testReactClient ( address ) ;
436
- const ts = Math . ceil ( Date . now ( ) / 1000 ) ;
441
+ // Tokens have a 3 second expiration, scheduled refetch occurs 2 seconds
442
+ // prior to expiration (so 1 second after token validation completes).
437
443
const tokens = [
438
- jwtEncode ( { iat : ts , exp : ts + 3 } , "token1" ) ,
439
- jwtEncode ( { iat : ts , exp : ts + 3 } , "token2" ) ,
440
- jwtEncode ( { iat : ts , exp : ts + 3 } , "token3" ) ,
441
- jwtEncode ( { iat : ts , exp : ts + 3 } , "token4" ) ,
444
+ ( ts : number ) => jwtEncode ( { iat : ts , exp : ts + 3 } , "token1" ) ,
445
+ ( ts : number ) => jwtEncode ( { iat : ts , exp : ts + 3 } , "token2" ) ,
446
+ ( ts : number ) => jwtEncode ( { iat : ts , exp : ts + 3 } , "token3" ) ,
447
+ ( ts : number ) => jwtEncode ( { iat : ts , exp : ts + 3 } , "token4" ) ,
442
448
] ;
443
449
const tokenFetcher = vi . fn ( async ( _opts ) => {
450
+ // Simulate a one second delay in token fetching - long enough to
451
+ // cause a scheduled refetch to occur while reauth is still running.
444
452
vi . advanceTimersByTime ( 1000 ) ;
445
- return tokens . shift ( ) ! ;
453
+ return tokens . shift ( ) ! ( Math . ceil ( Date . now ( ) / 1000 ) ) ;
446
454
} ) ;
447
455
const onChange = vi . fn ( ) ;
448
456
client . setAuth ( tokenFetcher , onChange ) ;
@@ -482,18 +490,29 @@ describe.sequential.skip("auth websocket tests", () => {
482
490
} ) ;
483
491
484
492
// A race condition where a user triggered query with a newly stale
485
- // token gets an auth error while reauth is refetching a fresh token .
493
+ // token triggers an auth error.
486
494
send ( {
487
495
type : "AuthError" ,
488
496
error : "Convex token identity expired" ,
489
497
baseVersion : 2 ,
490
498
} ) ;
491
499
close ( ) ;
492
500
501
+ // The error has now caused reauthentication to begin. Reauth stops the
502
+ // connection. The scheduled refetch will have started during the
503
+ // 1000ms token fetcher delay, causing the reauth attempt to exit early
504
+ // and never restart the connection.
505
+ //
506
+ // This is the race condition this test covers. The scheduled refetch
507
+ // previously would fetch a new token but never restart the connection
508
+ // stopped by reauth.
493
509
expect ( ( await receive ( ) ) . type ) . toEqual ( "Connect" ) ;
494
510
expect ( ( await receive ( ) ) . type ) . toEqual ( "Authenticate" ) ;
495
511
expect ( ( await receive ( ) ) . type ) . toEqual ( "ModifyQuerySet" ) ;
496
512
513
+ // If the connection is successfully restarted, the client will receive
514
+ // the following Transition message and call onChange a second time with
515
+ // true for `isAuthenticated`.
497
516
send ( {
498
517
type : "Transition" ,
499
518
startVersion : {
@@ -510,33 +529,50 @@ describe.sequential.skip("auth websocket tests", () => {
510
529
await client . close ( ) ;
511
530
512
531
expect ( tokenFetcher ) . toHaveBeenCalledTimes ( 4 ) ;
532
+ // Initial setConfig
513
533
expect ( tokenFetcher ) . toHaveBeenNthCalledWith ( 1 , {
514
534
forceRefreshToken : false ,
515
535
} ) ;
536
+ // Initial fresh token fetch
516
537
expect ( tokenFetcher ) . toHaveBeenNthCalledWith ( 2 , {
517
538
forceRefreshToken : true ,
518
539
} ) ;
540
+ // Reauth attempt
519
541
expect ( tokenFetcher ) . toHaveBeenNthCalledWith ( 3 , {
520
542
forceRefreshToken : true ,
521
543
} ) ;
544
+ // Scheduled refetch
522
545
expect ( tokenFetcher ) . toHaveBeenNthCalledWith ( 4 , {
523
546
forceRefreshToken : true ,
524
547
} ) ;
548
+
549
+ // Confirm that auth state changed exactly twice, and was never
550
+ // set to false.
525
551
expect ( onChange ) . toHaveBeenCalledTimes ( 2 ) ;
552
+ // Initial setConfig
526
553
expect ( onChange ) . toHaveBeenNthCalledWith ( 1 , true ) ;
554
+ // Refetch after reauth
527
555
expect ( onChange ) . toHaveBeenNthCalledWith ( 2 , true ) ;
528
556
vi . useRealTimers ( ) ;
529
557
} ) ;
530
558
} ) ;
531
559
560
+ // When awaiting server confirmation of a fresh token, a subsequent
561
+ // auth error (from an Authenticate request) will cause the client to go to
562
+ // an unauthenticated state. This test covers a race condition where an
563
+ // Authenticate request for a fresh token is sent, and then the client app
564
+ // goes to background and misses the Transition response. If the client
565
+ // becomes active after the new token has expired, a new Authenticate request
566
+ // will be sent with the expired token, leading to an error response and
567
+ // unauthenticated state.
532
568
test ( "Client retries token validation on error" , async ( ) => {
533
569
await withInMemoryWebSocket ( async ( { address, receive, send, close } ) => {
534
570
const client = testReactClient ( address ) ;
535
571
const ts = Math . ceil ( Date . now ( ) / 1000 ) ;
536
572
const tokens = [
537
- jwtEncode ( { iat : ts , exp : ts + 1000 } , "token1" ) ,
538
- jwtEncode ( { iat : ts , exp : ts + 1000 } , "token2" ) ,
539
- jwtEncode ( { iat : ts , exp : ts + 1000 } , "token3" ) ,
573
+ jwtEncode ( { iat : ts , exp : ts + 60 } , "token1" ) ,
574
+ jwtEncode ( { iat : ts , exp : ts + 60 } , "token2" ) ,
575
+ jwtEncode ( { iat : ts , exp : ts + 60 } , "token3" ) ,
540
576
] ;
541
577
const tokenFetcher = vi . fn ( async ( _opts ) => tokens . shift ( ) ! ) ;
542
578
const onChange = vi . fn ( ) ;
@@ -563,14 +599,16 @@ describe.sequential.skip("auth websocket tests", () => {
563
599
564
600
expect ( ( await receive ( ) ) . type ) . toEqual ( "Authenticate" ) ;
565
601
566
- // Simulating an unexpected network error
602
+ // Simulating an auth error while waiting for server confirmation of a
603
+ // fresh token.
567
604
send ( {
568
605
type : "AuthError" ,
569
606
error : "bla" ,
570
607
baseVersion : 1 ,
571
608
} ) ;
572
609
close ( ) ;
573
610
611
+ // The client should reattempt reauthentication.
574
612
expect ( ( await receive ( ) ) . type ) . toEqual ( "Connect" ) ;
575
613
expect ( ( await receive ( ) ) . type ) . toEqual ( "Authenticate" ) ;
576
614
expect ( ( await receive ( ) ) . type ) . toEqual ( "ModifyQuerySet" ) ;
@@ -588,21 +626,27 @@ describe.sequential.skip("auth websocket tests", () => {
588
626
modifications : [ ] ,
589
627
} ) ;
590
628
629
+ // Flush
591
630
await new Promise ( ( resolve ) => setTimeout ( resolve ) ) ;
592
631
await client . close ( ) ;
593
632
594
633
expect ( tokenFetcher ) . toHaveBeenCalledTimes ( 3 ) ;
634
+ // Initial setConfig
595
635
expect ( tokenFetcher ) . toHaveBeenNthCalledWith ( 1 , {
596
636
forceRefreshToken : false ,
597
637
} ) ;
638
+ // Initial fresh token fetch
598
639
expect ( tokenFetcher ) . toHaveBeenNthCalledWith ( 2 , {
599
640
forceRefreshToken : true ,
600
641
} ) ;
642
+ // Reauth second attempt
601
643
expect ( tokenFetcher ) . toHaveBeenNthCalledWith ( 3 , {
602
644
forceRefreshToken : true ,
603
645
} ) ;
604
646
expect ( onChange ) . toHaveBeenCalledTimes ( 2 ) ;
647
+ // Initial setConfig
605
648
expect ( onChange ) . toHaveBeenNthCalledWith ( 1 , true ) ;
649
+ // Reauth second attempt
606
650
expect ( onChange ) . toHaveBeenNthCalledWith ( 2 , true ) ;
607
651
} ) ;
608
652
} ) ;
0 commit comments