@@ -406,4 +406,235 @@ describe('TokenService', () => {
406
406
expect ( service . getIdir ( ) ) . toBe ( '' ) ;
407
407
} ) ;
408
408
} ) ;
409
+
410
+ describe ( 'hasAllScopesFromHash' , ( ) => {
411
+ const call = ( hash : string , required : string [ ] ) =>
412
+ ( service as any ) . hasAllScopesFromHash ( hash , required ) ;
413
+
414
+ afterEach ( ( ) => {
415
+ // clean up hash after each test
416
+ window . history . pushState ( { } , '' , '/' ) ;
417
+ } ) ;
418
+
419
+ it ( 'returns true when all explicit scopes are present (space encoded as %20)' , ( ) => {
420
+ const hash = '#access_token=t&scope=FOO%20BAR%20BAZ' ;
421
+ expect ( call ( hash , [ 'FOO' , 'BAR' ] ) ) . toBeTrue ( ) ;
422
+ } ) ;
423
+
424
+ it ( 'returns true when scopes are + separated (IdPs may encode spaces as +)' , ( ) => {
425
+ const hash = '#access_token=t&scope=FOO+BAR+BAZ' ;
426
+ expect ( call ( hash , [ 'FOO' , 'BAR' ] ) ) . toBeTrue ( ) ;
427
+ } ) ;
428
+
429
+ it ( 'supports wildcard prefix (WFDM.*) when at least one WFDM scope is present' , ( ) => {
430
+ const hash = '#access_token=t&scope=WFDM.CREATE_FILE%20WFPREV.GET_TOPLEVEL' ;
431
+ expect ( call ( hash , [ 'WFDM.*' ] ) ) . toBeTrue ( ) ;
432
+ } ) ;
433
+
434
+ it ( 'fails wildcard check when no scope matches the prefix' , ( ) => {
435
+ const hash = '#access_token=t&scope=WFPREV.GET_TOPLEVEL' ;
436
+ expect ( call ( hash , [ 'WFDM.*' ] ) ) . toBeFalse ( ) ;
437
+ } ) ;
438
+
439
+ it ( 'returns false if an explicit required scope is missing' , ( ) => {
440
+ const hash = '#access_token=t&scope=FOO%20BAR' ;
441
+ expect ( call ( hash , [ 'FOO' , 'MISSING' ] ) ) . toBeFalse ( ) ;
442
+ } ) ;
443
+
444
+ it ( 'treats empty required list as false' , ( ) => {
445
+ const hash = '#access_token=t&scope=ANY' ;
446
+ expect ( call ( hash , [ ] ) ) . toBeFalse ( ) ;
447
+ } ) ;
448
+ } ) ;
449
+
450
+ describe ( 'checkForToken — scope gate on access_token hash' , ( ) => {
451
+ const makeCfg = ( authScopes : string [ ] ) => ( {
452
+ application : {
453
+ lazyAuthenticate : true ,
454
+ enableLocalStorageToken : false ,
455
+ allowLocalExpiredToken : false ,
456
+ baseUrl : 'http://test.com' ,
457
+ localStorageTokenKey : 'test-oauth'
458
+ } ,
459
+ webade : {
460
+ oauth2Url : 'http://oauth.test' ,
461
+ clientId : 'test-client' ,
462
+ authScopes,
463
+ enableCheckToken : false
464
+ }
465
+ } ) ;
466
+
467
+ afterEach ( ( ) => {
468
+ window . history . pushState ( { } , '' , '/' ) ;
469
+ } ) ;
470
+
471
+ it ( 'redirects to error page when required scopes are missing' , async ( ) => {
472
+ window . history . pushState ( { } , '' , '/' ) ;
473
+
474
+ const cfg = makeCfg ( [ 'WFPREV.GET_TOPLEVEL' , 'WFDM.*' ] ) ;
475
+ ( mockAppConfigService . getConfig as jasmine . Spy ) . and . returnValue ( cfg ) ;
476
+
477
+ const service = new TokenService ( mockInjector , mockAppConfigService , mockSnackbarService , mockRouter ) ;
478
+ window . history . pushState ( { } , '' , '/#access_token=tok&scope=WFPREV.GET_TOPLEVEL' ) ;
479
+ const parseSpy = spyOn ( service as any , 'parseToken' ) ;
480
+
481
+ await service . checkForToken ( ) ;
482
+ expect ( mockRouter . navigate ) . toHaveBeenCalledWith ( [ '/' + ResourcesRoutes . ERROR_PAGE ] ) ;
483
+ expect ( parseSpy ) . not . toHaveBeenCalled ( ) ;
484
+ } ) ;
485
+
486
+ it ( 'parses token when all required scopes are present (including wildcard match)' , async ( ) => {
487
+ window . history . pushState ( { } , '' , '/' ) ;
488
+
489
+ const cfg = makeCfg ( [ 'WFPREV.GET_TOPLEVEL' , 'WFDM.*' ] ) ;
490
+ ( mockAppConfigService . getConfig as jasmine . Spy ) . and . returnValue ( cfg ) ;
491
+
492
+ const svc = new TokenService ( mockInjector , mockAppConfigService , mockSnackbarService , mockRouter ) ;
493
+
494
+ window . history . pushState (
495
+ { } , '' ,
496
+ '/#access_token=tok&scope=WFPREV.GET_TOPLEVEL%20WFDM.UPDATE_FILE'
497
+ ) ;
498
+
499
+ const parseSpy = spyOn ( svc as any , 'parseToken' ) ;
500
+
501
+ await svc . checkForToken ( ) ;
502
+
503
+ expect ( parseSpy ) . toHaveBeenCalledWith ( globalThis . location . hash ) ;
504
+ expect ( mockRouter . navigate ) . not . toHaveBeenCalled ( ) ;
505
+ } ) ;
506
+
507
+ it ( 'accepts "+"-separated scopes as space (IdP encoding quirk)' , async ( ) => {
508
+ window . history . pushState ( { } , '' , '/' ) ;
509
+
510
+ const cfg = makeCfg ( [ 'FOO' , 'BAR' ] ) ;
511
+ ( mockAppConfigService . getConfig as jasmine . Spy ) . and . returnValue ( cfg ) ;
512
+
513
+ const svc = new TokenService ( mockInjector , mockAppConfigService , mockSnackbarService , mockRouter ) ;
514
+
515
+ window . history . pushState ( { } , '' , '/#access_token=tok&scope=FOO+BAR' ) ;
516
+
517
+ const parseSpy = spyOn ( svc as any , 'parseToken' ) ;
518
+
519
+ await svc . checkForToken ( ) ;
520
+
521
+ expect ( parseSpy ) . toHaveBeenCalled ( ) ;
522
+ expect ( mockRouter . navigate ) . not . toHaveBeenCalled ( ) ;
523
+ } ) ;
524
+ } ) ;
525
+
526
+ describe ( 'checkForToken — offline local storage branch' , ( ) => {
527
+ const makeJwt = ( expSecondsFromNow : number ) => {
528
+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
529
+ const payload = { exp : now + expSecondsFromNow } ;
530
+ const b64 = btoa ( JSON . stringify ( payload ) ) ;
531
+ return `h.${ b64 } .s` ;
532
+ } ;
533
+
534
+ const setNavigatorOnline = ( val : boolean ) => {
535
+ Object . defineProperty ( navigator , 'onLine' , { value : val , configurable : true } ) ;
536
+ } ;
537
+
538
+ afterEach ( ( ) => {
539
+ window . history . pushState ( { } , '' , '/' ) ;
540
+ setNavigatorOnline ( true ) ;
541
+ localStorage . clear ( ) ;
542
+ } ) ;
543
+
544
+ it ( 'offline: expired local token ⇒ clearLocalToken + initLogin are called' , async ( ) => {
545
+ const cfg = {
546
+ application : {
547
+ lazyAuthenticate : true ,
548
+ enableLocalStorageToken : false ,
549
+ allowLocalExpiredToken : false ,
550
+ baseUrl : 'http://test.com' ,
551
+ localStorageTokenKey : 'test-oauth' ,
552
+ } ,
553
+ webade : { oauth2Url : 'http://oauth.test' , clientId : 'test-client' , authScopes : [ ] , enableCheckToken : false }
554
+ } ;
555
+ ( mockAppConfigService . getConfig as jasmine . Spy ) . and . returnValue ( cfg ) ;
556
+
557
+ const svc = new TokenService ( mockInjector , mockAppConfigService , mockSnackbarService , mockRouter ) ;
558
+
559
+ ( svc as any ) . useLocalStore = true ;
560
+
561
+ // seed an EXPIRED token in localStorage (exp = now - 60s)
562
+ const expiredJwt = makeJwt ( - 60 ) ;
563
+ localStorage . setItem ( 'test-oauth' , JSON . stringify ( { access_token : expiredJwt , expires_in : 3600 } ) ) ;
564
+
565
+ // go offline so we enter the offline/local branch
566
+ setNavigatorOnline ( false ) ;
567
+
568
+ const clearSpy = spyOn ( svc as any , 'clearLocalToken' ) . and . callThrough ( ) ;
569
+ const initLoginSpy = spyOn ( svc as any , 'initLogin' ) . and . callThrough ( ) ;
570
+
571
+ await svc . checkForToken ( 'http://redir' , true , false ) ;
572
+
573
+ expect ( clearSpy ) . toHaveBeenCalled ( ) ;
574
+ expect ( initLoginSpy ) . toHaveBeenCalledWith ( 'http://redir' ) ;
575
+ } ) ;
576
+
577
+ it ( 'offline: non-expired local token ⇒ does NOT clear or login' , async ( ) => {
578
+ const cfg = {
579
+ application : {
580
+ lazyAuthenticate : true ,
581
+ enableLocalStorageToken : false ,
582
+ allowLocalExpiredToken : false ,
583
+ baseUrl : 'http://test.com' ,
584
+ localStorageTokenKey : 'test-oauth' ,
585
+ } ,
586
+ webade : { oauth2Url : 'http://oauth.test' , clientId : 'test-client' , authScopes : [ ] , enableCheckToken : false }
587
+ } ;
588
+ ( mockAppConfigService . getConfig as jasmine . Spy ) . and . returnValue ( cfg ) ;
589
+
590
+ const svc = new TokenService ( mockInjector , mockAppConfigService , mockSnackbarService , mockRouter ) ;
591
+ ( svc as any ) . useLocalStore = true ;
592
+
593
+ // seed a VALID token (exp = now + 1h)
594
+ const validJwt = makeJwt ( 3600 ) ;
595
+ localStorage . setItem ( 'test-oauth' , JSON . stringify ( { access_token : validJwt , expires_in : 3600 } ) ) ;
596
+
597
+ setNavigatorOnline ( false ) ;
598
+
599
+ const clearSpy = spyOn ( svc as any , 'clearLocalToken' ) ;
600
+ const initLoginSpy = spyOn ( svc as any , 'initLogin' ) ;
601
+
602
+ await svc . checkForToken ( undefined , true , false ) ;
603
+
604
+ expect ( clearSpy ) . not . toHaveBeenCalled ( ) ;
605
+ expect ( initLoginSpy ) . not . toHaveBeenCalled ( ) ;
606
+ } ) ;
607
+
608
+ it ( 'offline: expired but allowed (allowLocalExpiredToken=true) ⇒ no clear/login' , async ( ) => {
609
+ const cfg = {
610
+ application : {
611
+ lazyAuthenticate : true ,
612
+ enableLocalStorageToken : false ,
613
+ allowLocalExpiredToken : false ,
614
+ baseUrl : 'http://test.com' ,
615
+ localStorageTokenKey : 'test-oauth' ,
616
+ } ,
617
+ webade : { oauth2Url : 'http://oauth.test' , clientId : 'test-client' , authScopes : [ ] , enableCheckToken : false }
618
+ } ;
619
+ ( mockAppConfigService . getConfig as jasmine . Spy ) . and . returnValue ( cfg ) ;
620
+
621
+ const svc = new TokenService ( mockInjector , mockAppConfigService , mockSnackbarService , mockRouter ) ;
622
+ ( svc as any ) . useLocalStore = true ;
623
+
624
+ const expiredJwt = makeJwt ( - 60 ) ;
625
+ localStorage . setItem ( 'test-oauth' , JSON . stringify ( { access_token : expiredJwt , expires_in : 3600 } ) ) ;
626
+
627
+ setNavigatorOnline ( false ) ;
628
+
629
+ const clearSpy = spyOn ( svc as any , 'clearLocalToken' ) ;
630
+ const initLoginSpy = spyOn ( svc as any , 'initLogin' ) ;
631
+
632
+ await svc . checkForToken ( undefined , true , true ) ;
633
+
634
+ expect ( clearSpy ) . not . toHaveBeenCalled ( ) ;
635
+ expect ( initLoginSpy ) . not . toHaveBeenCalled ( ) ;
636
+ } ) ;
637
+ } ) ;
638
+
639
+
409
640
} ) ;
0 commit comments