okta / okta-auth-js

The official js wrapper around Okta's auth API
Other
450 stars 263 forks source link

Mocking OktaAuth #584

Open Fusekki opened 3 years ago

Fusekki commented 3 years ago

I am currently working on an app using Angular 8 with the okta-auth-js library version 4.5.

Previously, in the 3.x and earlier, I was easily able to mock the okta library, however, with this one I am facing difficulties. Here is the current mock I am trying to use:

    mockedOktaClient: {
      signInWithCredentials() {
        return new Promise(resolve => resolve( CONFIG.mockedAuthToken.transaction));
      },
      token: {
        async getWithoutPrompt(): Promise<any> {
          return new Promise(resolve => resolve( CONFIG.oktaToken));
        },
        async getUserInfo() {
          return CONFIG.getUserDetails;
        }
      },
      async updateAuthState() {
        return new Promise(resolve => resolve (null));
      },
      async revokeAccessToken() {
      },
      async signOut() {
        return;
      },
      closeSession() {
        return Promise.resolve(true);
      },
      tokenManager: {
        add(tokenType, token) {
          return [
            CONFIG.validationToken
          ];
        }
      }
    }

And in the spec.ts I am doing this:

import { TestBed, async, ComponentFixture, tick, fakeAsync } from '@angular/core/testing';
import { ReactiveFormsModule, FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
import { CookieService } from 'ngx-cookie-service';
import { AuthenticationService } from 'src/app/core/services/authentication.service';
import { MaterialModule } from 'src/app/material/material.module';
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material';
import { OAuthService, UrlHelperService, OAuthLogger } from 'angular-oauth2-oidc';
import { HttpClient, HttpHandler } from '@angular/common/http';
import { AppConfigService } from 'src/app/core/services/app-config.service';
import { ManageUserSharedService } from '../../../core/services/manage-user-shared.service';
import { CONFIG, configNotLocal, usersDetails } from 'src/UnitTest-Support/Mocks/data.mock';
import { UserPreferencesService } from '../../../core/services/user-preferences.service';
import { Observable, of, Subject } from 'rxjs';
import { Router, Routes, ActivatedRoute } from '@angular/router';
import { HttpErrorHandlerService } from 'src/app/core/services/http-error-handler.service';
import { MockedHttpErrorHandlerService } from 'e2e/src/mocks/mocked-http-error-handler.service';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { CustomSnackbarComponent } from '../custom-snackbar/custom-snackbar.component';
import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing';
import { ExternRouteComponent } from '../extern-route/extern-route.component';
import * as OktaSignIn from '@okta/okta-signin-widget';
import { OktaAuth } from '@okta/okta-auth-js';

describe('LoginComponent', () => {
  let cookieService: CookieService;
  let component: LoginComponent;
  let fixture: ComponentFixture<LoginComponent>;
  let authService: AuthenticationService;
  let appConfig: AppConfigService;
  let userPreferencesService: UserPreferencesService;
  let moveSharedService: ManageUserSharedService;
  let router: Router;
  let el: HTMLElement;
  const params = new Subject<{ [ key: string ]: any }>();
  const activatedRouteMock = { queryParams: params.asObservable() };
  let oktaMock: OktaAuth;

describe('LoginComponent', () => {
  let cookieService: CookieService;
  let component: LoginComponent;
  let fixture: ComponentFixture<LoginComponent>;
  let authService: AuthenticationService;
  let appConfig: AppConfigService;
  let userPreferencesService: UserPreferencesService;
  let moveSharedService: ManageUserSharedService;
  let router: Router;
  let el: HTMLElement;
  const params = new Subject<{ [ key: string ]: any }>();
  const activatedRouteMock = { queryParams: params.asObservable() };
  let oktaMock: OktaAuth;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        MaterialModule,
        RouterTestingModule,
        ReactiveFormsModule,
        FormsModule,
        BrowserAnimationsModule,
        RouterTestingModule.withRoutes(routes)],
      declarations: [
        CustomSnackbarComponent,
        ExternRouteComponent,
      ],
      schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA],
      providers: [
        { provide: MatDialogRef, useValue: [] },
        { provide: MAT_DIALOG_DATA, useValue: [] },
        { provide: HttpErrorHandlerService, useClass: MockedHttpErrorHandlerService },
        OAuthService, HttpClient, HttpHandler, UrlHelperService, OAuthLogger,
        { provide: 'appConfig', useValue: CONFIG },
        { provide: ActivatedRoute, useValue: activatedRouteMock },
        { provide: 'OktaSignIn', useValue: CONFIG.mockedOktaClient },
        { provide: OktaAuth, useValue: CONFIG.mockedOktaClient },
        { provide: CookieService, useValue: CONFIG.mockedCookieService },
        UserPreferencesService
      ],
    })
      .overrideModule(BrowserDynamicTestingModule, { set: { entryComponents: [CustomSnackbarComponent] } })
      .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
    appConfig = TestBed.get(AppConfigService);
    authService = TestBed.get(AuthenticationService);
    moveSharedService = TestBed.get(ManageUserSharedService);
    router = TestBed.get(Router);
    userPreferencesService = TestBed.get(UserPreferencesService);
    cookieService = TestBed.get(CookieService);
    oktaMock = TestBed.get(OktaAuth);
  });

Yet when trying to implement tests for this component, I am getting intermittent failures. Sometimes, I get a

WARN: '[okta-auth-sdk] WARN: updateAuthState is an asynchronous method with no return, please subscribe to the latest authState update with authStateManager.subscribe(handler) method before calling updateAuthState.'

or

AuthSdkError: OAuth flow timed out
    at http://localhost:9876/_karma_webpack_/node_modules/@okta/okta-auth-js/dist/okta-auth-js.umd.js:351:3559
    at ZoneDelegate.invokeTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:391:1)
    at ProxyZoneSpec.push../node_modules/zone.js/dist/zone-testing.js.ProxyZoneSpec.onInvokeTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-testing.js:339:1)
    at ZoneDelegate.invokeTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:390:1)
    at Object.onInvokeTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:273:1)
    at ZoneDelegate.invokeTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:390:1)
    at Object.onInvokeTask (http://localhost:9876/_karma_webpack_/node_modules/@angular/core/fesm2015/core.js:39680:1)
    at ZoneDelegate.invokeTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:390:1)
    at Zone.runTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:168:1)

or the tests just fail. Anyone had better success with it? I can post more of the file if needed.

shuowu commented 3 years ago

@staffordp You can ignore the WARN.

The error message

AuthSdkError: OAuth flow timed out

does like some mock is missing, can you provide any test case that fails, then I can look into the issue you have.

Fusekki commented 3 years ago

Looks like this one produced the error a minute or two after it reported success:

  it('should perform an external redirect to domain if userDetails is something and rolename is something', fakeAsync(() => {
    spyOn(authService.authClient.token, 'getWithoutPrompt').and.callFake(() => {
      return new Promise(resolve => resolve(null));
    });
    spyOn(authService, 'resetPasswordAttempts').and.callFake(() => {
      return Promise.resolve(CONFIG.clearPasswordAttemptResponse);
    });
    spyOn(userPreferencesService, 'getPreference').and.callFake(
      (): Observable<any> => {
        return of(configNotLocal.something);
      }
    );
    const spy: jasmine.Spy = spyOn(router, 'navigate');
    moveSharedService.updateData(usersDetails[2]);
    fixture.detectChanges();
    fixture.ngZone.run(() => { // Prevent "Navigation triggered outside Angular zone" warning
      component.onLoginResponse(CONFIG.mockedwidgetResponse);
      tick(500);
      expect(spy).toHaveBeenCalledWith(['/externalRedirect',
      Object({ externalUrl: 'https://somewebsite/' }) ],
      Object({ skipLocationChange: true }));
    });
  }));
Fusekki commented 3 years ago

I also noticed, I get intermittent failures on this test:

  it('call the store cookies on a OnWidget response success', fakeAsync (() => {
    console.info('test', 8);
    spyOn(authService, 'resetPasswordAttempts').and.callFake(() => {
      return Promise.resolve(CONFIG.clearPasswordAttemptResponse);
    });
    spyOn(authService.authClient.token, 'getWithoutPrompt').and.callFake(() => {
      return new Promise(resolve => resolve(null));
    });
    const spy = spyOn(cookieService, 'set').and.callThrough();
    moveSharedService.updateData(usersDetails[1]);
    fixture.detectChanges();
    fixture.ngZone.run(() => { // Prevent " triggered outside Angular zone" warning
      component.onLoginResponse(CONFIG.mockedwidgetResponse);
    });
    tick(500);
    expect(spy).toHaveBeenCalledTimes(3);
  }));

Error I receive is

        Error: Cannot make XHRs from within a fake async test. Request URL: https://cartus.oktapreview.com/oauth2/auspye2nuzSGJ0Kje0h7/.well-known/openid-configuration
        error properties: Object({ longStack: 'Error: Cannot make XHRs from within a fake async test. Request URL: https://cartus.oktapreview.com/oauth2/auspye2nuzSGJ0Kje0h7/.well-known/openid-configuration
            at FakeAsyncTestZoneSpec.push../node_modules/zone.js/dist/zone-testing.js.FakeAsyncTestZoneSpec.onScheduleTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-testing.js:1334:1)

But usually it passes fine. I suspect this code may be causing the failure and my mock of it, but not sure:

          this.authSvc.authClient.tokenManager.setTokens(
            {accessToken: res.tokens.idToken,
              idToken: res.tokens.accessToken}
          );

mocked client:

    mockedOktaClient: {
      signInWithCredentials() {
        return new Promise(resolve => resolve( CONFIG.mockedAuthToken.transaction));
      },
      token: {
        async getWithoutPrompt(): Promise<any> {
          return new Promise(resolve => resolve( CONFIG.oktaToken ));
        },
        async getUserInfo() {
          return CONFIG.getUserDetails;
        }
      },
      async revokeAccessToken() {
      },
      async signOut() {
        return;
      },
      closeSession() {
        return Promise.resolve(true);
      },
      isAuthenticated() {
        return Promise.resolve(true);
      },
      tokenManager: {
        add(tokenType, token) {
          return [
            CONFIG.validationToken
          ];
        },
        setTokens() {}
      },
      async updateAuthState() {
        return Promise.resolve({
          isPending: false,
          isAuthenticated: true,
          accessToken: accessToken,
          idToken: idToken,
          error: ''
        });
      },
      authStateManager: { 
        async updateAuthState() {
          return Promise.resolve({
            isPending: false,
            isAuthenticated: true,
            accessToken: accessToken,
            idToken: idToken,
            error: ''
          });
        },
      },
    },
Fusekki commented 3 years ago

Actually, I am not sure if this library can even be mocked any more. I've tried using both

{ provide: OktaAuth, useValue: CONFIG.mockedOktaClient },

{ provide: OktaAuth, useClass: CONFIG.mockedOktaStub },

and neither seem to work. Has anyone had success with this for testing via Jasmine?

aarongranick-okta commented 3 years ago

@staffordp I'm not sure about your last problem (not being able to mock), but the earlier problems may be the result of the "autoRenew" feature, which runs in the background to renew tokens. This will attempt to hit XHR and update auth state. You can disable this feature by setting autoRenew to false

tokenManager: {
  autoRenew: false
}
Fusekki commented 3 years ago

@aakashyadav-okta

I don't get the XHR error any more after ensuring I callFake() on every function call for each test. However, I am still experiencing this quite repeatedly:

ERROR: AuthSdkError: OAuth flow timed out

AuthSdkError: OAuth flow timed out
    at http://localhost:9876/_karma_webpack_/node_modules/@okta/okta-auth-js/dist/okta-auth-js.umd.js:339:3557
    at ZoneDelegate.invokeTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:391:1)
    at ProxyZoneSpec.push../node_modules/zone.js/dist/zone-testing.js.ProxyZoneSpec.onInvokeTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-testing.js:339:1)
    at ZoneDelegate.invokeTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:390:1)
    at Object.onInvokeTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:273:1)
    at ZoneDelegate.invokeTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:390:1)
    at Object.onInvokeTask (http://localhost:9876/_karma_webpack_/node_modules/@angular/core/fesm2015/core.js:39680:1)
    at ZoneDelegate.invokeTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:390:1)
    at Zone.runTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:168:1)
    at invokeTask (http://localhost:9876/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:465:1)
aarongranick-okta commented 3 years ago

@staffordp In version 4.6 we did add a new feature, which is to use fetch instead of XHR, if it is available. This did cause some problems in tests for us as well. The quickest solution is to set window.fetch = null in your test. window.fetch can also be mocked.

Fusekki commented 3 years ago

I tried doing a quick upgrade to 4.6 and it blew a ton of my tests away. I think I'll stick with working on 4.5 for now since I am no longer receiving the XHR errors. The only one I see now is below and it's very intermittent. Usually pops up at the end of running the tests:

ERROR: AuthSdkError: OAuth flow timed out AuthSdkError: OAuth flow timed out at http://localhost:9876/_karma_webpack_/node_modules/@okta/okta-auth-js/dist/okta-auth-js.umd.js:339:3557

aarongranick-okta commented 3 years ago

@staffordp The OAuth flow timed out error is often seen when calling token.getWithoutPrompt which opens an iframe and waits for a message of success or failure from the embedded page. This method is called when calling token.renew. It could happen automatically if tokenManager.autoRenew is not set to false

Fusekki commented 3 years ago

@aaronbrodersen-okta As of version 3.x, was autoRenew defaulted to true? or false?

I also set autoRenew: false in the config and I still get this error:

ERROR: AuthSdkError: OAuth flow timed out
AuthSdkError: OAuth flow timed out
    at http://localhost:9876/_karma_webpack_/node_modules/@okta/okta-auth-js/dist/okta-auth-js.umd.js:339:3557

My config:

    this.authClient = new OktaAuth({
      clientId: this.appConfig.getConfig('oktaClientId').toString(),
      issuer: this.appConfig.getConfig('oktaUrl').toString(),
      redirectUri: this.appConfig.getConfig('oktaRedirectUri').toString(),
      postLogoutRedirectUri: this.appConfig.getConfig('oktaRedirectUri').toString(),
      tokenManager: {
        storage: 'sessionStorage',
        autoRenew: false
      },
    });
aarongranick-okta commented 3 years ago

@staffordp The error is thrown here. The post message listener is added when tokens are being retrieved by iframe or popup window. Most likely case is iframe due to token renewal.

If the error is happening in a test, you could add a spy/mock to authClient.token.getWithoutPrompt to throw immediately. Then you should see which piece of code is calling it.

Fusekki commented 3 years ago

@aaronbrodersen-okta Thanks. I have been doing the spy callFake for each test that includes a call to authClient.token.getWithoutPrompt. I am still progressing through it...

Fusekki commented 3 years ago

@aaronbrodersen-okta Noticed a few of the tests had leaks that were calling authClient.token.getWithoutPrompt and were not being spied upon with a callFake return. After plugging those leaks, I've seen the error message disappear. Am still running tests continually to verify nothing is left open, but appears to be fine at this point. Am still curious if anyone has been able to successfully mock the library either with a stub or a mock with useClass or useValue for the providers. As it is, I am not including the OAuth provider in the spec file.

Fusekki commented 3 years ago

I've noticed an additional issue in attempting to Spy on all functions in the specs.

I have noticed issues with callFake on closeSession() calls.

In my beforeEach segment, I have the following to fake the call:

spyOn(authService.authClient, 'closeSession').and.callFake(() => {
  return new Promise<any>(resolve => resolve(null));
});

I've noticed when the first test runs and closeSession is called, no network call is attempted in the network calls debug page of Chrome. However, subsequent calls to this method in different tests using this same spec with the same spyOn above, I am seeing network calls:

image

From the image, it appears it is not using the CallFake method. Does anyone know a fix for this? I'd rather avoid unnecessary network calls for UTs.

aarongranick-okta commented 3 years ago

@staffordp /sessions/me is part of our sessions api. It is not called internally by the okta-auth-js SDK. Possibly you have some code calling session.get or session.exists ? This piece of code:

        async getUserInfo() {
          return CONFIG.getUserDetails;
        }

hints that getUserInfo may called. Internally this calls. /sessions/me.

Back to your original question. Why is it hitting live endpoints? Why is it executing any Okta logic at all?

Is the object mocked or not mocked? If OktaAuth is fully mocked, including the constructor, then it should not even try to hit any live endpoints; there is no Okta logic involved. This is the recommended approach. You don't need to TEST Okta AuthJS, you only need to mock the parts of the API you are using and verify it is being called correctly.

However if a live AuthJS instance is used, with some methods spied/mocked then the constructor will run and this is where the config is applied. So IF you want to use a live instance of okta-auth-js, with some methods spied/mocked, be sure to set tokenManager.autoRenew to false to prevent background token logic. I think this is the likely source of the issue you are seeing.

By mocking the object completely (or by setting tokenManager.autoRenew config), you should be able to avoid any attempts at network requests.

However, in the cases where you DO want to mock network responses, then I think the most likely reason code would be hitting live endpoints (which were otherwise mocked) is because it ended prematurely. Be sure to mark the test as async and wait for all expected network calls to complete before resolving the test as successful. Time-dependent logic is usually handled best by mocking the clock using jasmine, jest, sinon or some other testing library.

sumit-rajput-sr commented 3 years ago

I'm trying to mock the login in cypress. But token.getWithoutPrompt() is throwing OAuthError. Does anyone have any idea about this?

NazmiAltun commented 2 days ago

Currently we need something similar for our application as well. Is there any solution to mock okta auth out for e2e tests?