Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions src/app/shared/idle-modal/idle-modal.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()],
Expand Down Expand Up @@ -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);
});
Comment thread
Copilot marked this conversation as resolved.
});

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<AuthTokenInfo>();
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<AuthTokenInfo>();
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', () => {
Expand Down
37 changes: 35 additions & 2 deletions src/app/shared/idle-modal/idle-modal.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -24,6 +26,16 @@ export class IdleModalComponent implements OnInit {
*/
private graceTimer;

/**
* Guards against multiple rapid extension attempts.
*/
private extending = false;

Comment thread
amadulhaxxani marked this conversation as resolved.
/**
* Tracks the in-flight refresh subscription for cancellation on logout.
*/
private refreshSubscription: Subscription;

/**
* An event fired when the modal is closed
*/
Expand Down Expand Up @@ -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();
}
});
Comment thread
amadulhaxxani marked this conversation as resolved.
}

/**
* 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);
}
Expand Down
Loading