diff --git a/src/app/shared/idle-modal/idle-modal.component.spec.ts b/src/app/shared/idle-modal/idle-modal.component.spec.ts index 7ea0b96d5be..d1be0976fa3 100644 --- a/src/app/shared/idle-modal/idle-modal.component.spec.ts +++ b/src/app/shared/idle-modal/idle-modal.component.spec.ts @@ -2,11 +2,13 @@ import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf, Subject, throwError } from 'rxjs'; import { IdleModalComponent } from './idle-modal.component'; import { AuthService } from '../../core/auth/auth.service'; import { By } from '@angular/platform-browser'; import { Store } from '@ngrx/store'; -import { LogOutAction } from '../../core/auth/auth.actions'; +import { LogOutAction, RefreshTokenSuccessAction } from '../../core/auth/auth.actions'; +import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; describe('IdleModalComponent', () => { let component: IdleModalComponent; @@ -19,7 +21,10 @@ describe('IdleModalComponent', () => { beforeEach(waitForAsync(() => { modalStub = jasmine.createSpyObj('modalStub', ['close']); - authServiceStub = jasmine.createSpyObj('authService', ['setIdle']); + authServiceStub = jasmine.createSpyObj('authService', ['setIdle', 'getToken', 'refreshAuthenticationToken']); + const token = new AuthTokenInfo('test-token'); + authServiceStub.getToken.and.returnValue(token); + authServiceStub.refreshAuthenticationToken.and.returnValue(observableOf(token)); storeStub = jasmine.createSpyObj('store', ['dispatch']); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], @@ -58,6 +63,61 @@ describe('IdleModalComponent', () => { it('response \'closed\' should emit true', () => { expect(component.response.emit).toHaveBeenCalledWith(true); }); + + it('should refresh authentication token with current token', () => { + const currentToken = authServiceStub.getToken(); + expect(authServiceStub.refreshAuthenticationToken).toHaveBeenCalledWith(currentToken); + }); + + it('should dispatch refreshed token before closing modal', () => { + expect(storeStub.dispatch).toHaveBeenCalledWith(new RefreshTokenSuccessAction(authServiceStub.getToken())); + const dispatchOrder = storeStub.dispatch.calls.first().invocationOrder; + const closeOrder = modalStub.close.calls.first().invocationOrder; + expect(dispatchOrder).toBeLessThan(closeOrder); + }); + }); + + describe('extendSessionAndCloseModal hardening', () => { + it('should dispatch LogOutAction and close modal when refresh fails', () => { + authServiceStub.refreshAuthenticationToken.and.returnValue(throwError(() => new Error('refresh failed'))); + spyOn(component, 'closeModal').and.callThrough(); + + component.extendSessionAndCloseModal(); + + expect(storeStub.dispatch).toHaveBeenCalledWith(new LogOutAction()); + expect(component.closeModal).toHaveBeenCalled(); + expect(modalStub.close).toHaveBeenCalled(); + }); + + it('should prevent duplicate refresh requests on rapid double click', () => { + const refresh$ = new Subject(); + authServiceStub.refreshAuthenticationToken.and.returnValue(refresh$.asObservable()); + + component.extendSessionAndCloseModal(); + component.extendSessionAndCloseModal(); + + expect(authServiceStub.refreshAuthenticationToken).toHaveBeenCalledTimes(1); + + refresh$.next(new AuthTokenInfo('updated-token')); + refresh$.complete(); + }); + + it('should not close modal before token refresh completes', () => { + const refresh$ = new Subject(); + authServiceStub.refreshAuthenticationToken.and.returnValue(refresh$.asObservable()); + spyOn(component, 'closeModal').and.callThrough(); + + component.extendSessionAndCloseModal(); + + expect(component.closeModal).not.toHaveBeenCalled(); + expect(modalStub.close).not.toHaveBeenCalled(); + + refresh$.next(new AuthTokenInfo('updated-token')); + refresh$.complete(); + + expect(component.closeModal).toHaveBeenCalledTimes(1); + expect(modalStub.close).toHaveBeenCalledTimes(1); + }); }); describe('logOutPressed', () => { diff --git a/src/app/shared/idle-modal/idle-modal.component.ts b/src/app/shared/idle-modal/idle-modal.component.ts index 4873137ff1e..fbd6a6e1b02 100644 --- a/src/app/shared/idle-modal/idle-modal.component.ts +++ b/src/app/shared/idle-modal/idle-modal.component.ts @@ -5,7 +5,9 @@ import { AuthService } from '../../core/auth/auth.service'; import { hasValue } from '../empty.util'; import { Store } from '@ngrx/store'; import { AppState } from '../../app.reducer'; -import { LogOutAction } from '../../core/auth/auth.actions'; +import { LogOutAction, RefreshTokenSuccessAction } from '../../core/auth/auth.actions'; +import { Subscription } from 'rxjs'; +import { finalize, take } from 'rxjs/operators'; @Component({ selector: 'ds-idle-modal', @@ -24,6 +26,16 @@ export class IdleModalComponent implements OnInit { */ private graceTimer; + /** + * Guards against multiple rapid extension attempts. + */ + private extending = false; + + /** + * Tracks the in-flight refresh subscription for cancellation on logout. + */ + private refreshSubscription: Subscription; + /** * An event fired when the modal is closed */ @@ -71,17 +83,38 @@ export class IdleModalComponent implements OnInit { * Close the modal and extend session */ extendSessionAndCloseModal() { + if (this.extending) { + return; + } + this.extending = true; + if (hasValue(this.graceTimer)) { clearTimeout(this.graceTimer); } + this.authService.setIdle(false); - this.closeModal(); + + this.refreshSubscription = this.authService.refreshAuthenticationToken(this.authService.getToken()).pipe( + take(1), + finalize(() => this.extending = false) + ).subscribe({ + next: (token) => { + this.store.dispatch(new RefreshTokenSuccessAction(token)); + this.closeModal(); + }, + error: () => { + this.store.dispatch(new LogOutAction()); + this.closeModal(); + } + }); } /** * Close the modal and set the response to true so RootComponent knows the modal was closed */ closeModal() { + this.refreshSubscription?.unsubscribe(); + this.extending = false; this.activeModal.close(); this.response.emit(true); }