Skip to content

Commit

Permalink
(HDS-2107) Fix test with window redirection tracking
Browse files Browse the repository at this point in the history
In some cases the redirects where not registered correctly.

Everything worked locally, so changed how login / logout are tracked rather than blindly test stuff.

The might have been two problems:
- oidc-client-ts fetches openid config before redirects. If that fails, login/logout is not done.
- oidc-client-ts uses bind() when redirecting and jest.fn() loses its scope with when bound to other context.

Oddly both of these should have caused tests to fail previously.
  • Loading branch information
NikoHelle committed Feb 5, 2024
1 parent 24724f2 commit 32f41bd
Show file tree
Hide file tree
Showing 4 changed files with 67 additions and 70 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,27 @@ export type MockedWindowLocationActions = {
export default function mockWindowLocation(): MockedWindowLocationActions {
const globalWin = (global as unknown) as Window;
let oldWindowLocation: Location | undefined = globalWin.location;

const unload = () => setTimeout(() => window.dispatchEvent(new Event('unload')), 20);
const tracker = jest.fn(unload);
const tracker = jest.fn();
const unload = (...args: unknown[]) => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
tracker(...args);
setTimeout(() => window.dispatchEvent(new Event('unload')), 20);
};
// If the tracker is directly set to "value" below,
// then calls done with "window.location.assign.bind(window.self)"
// will not be tracked. oidc-client-ts does this.
const trackerWithUnload = jest.fn(unload);
const location = Object.defineProperties(
{},
{
...Object.getOwnPropertyDescriptors(oldWindowLocation),
assign: {
enumerable: true,
value: tracker,
value: trackerWithUnload,
},
replace: {
enumerable: true,
value: tracker,
value: trackerWithUnload,
},
},
);
Expand All @@ -32,6 +39,7 @@ export default function mockWindowLocation(): MockedWindowLocationActions {
});

const getCalls = () => {
// console.log('calls', tracker.mock.calls);
return (tracker.mock.calls as unknown) as string[];
};

Expand Down
28 changes: 14 additions & 14 deletions packages/react/src/components/login/client/oidcClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ describe('oidcClient', () => {
const { userManager } = testData;
const signinRedirect = jest.spyOn(userManager, 'signinRedirect');
const loginParams = { language: 'sv' };
await waitForLoginToTimeout(loginParams);
await waitForLoginToTimeout(mockedWindowControls, loginParams);
expect(signinRedirect).toHaveBeenNthCalledWith(1, {
extraQueryParams: {
ui_locales: loginParams.language,
Expand All @@ -103,7 +103,7 @@ describe('oidcClient', () => {
nonce: 'nonce',
state: { stateValue: 1, path: '/applications' },
};
await waitForLoginToTimeout({ ...loginParams, language: 'sv' });
await waitForLoginToTimeout(mockedWindowControls, { ...loginParams, language: 'sv' });
expect(signinRedirect).toHaveBeenNthCalledWith(1, {
...loginParams,
extraQueryParams: {
Expand All @@ -123,33 +123,33 @@ describe('oidcClient', () => {
it('should add given language to the logout url', async () => {
const { oidcClient, userManager } = testData;
const signoutRedirectSpy = jest.spyOn(userManager, 'signoutRedirect');
const loginParams = { language: 'sv' };
await waitForLogoutToTimeout(loginParams);
const logoutParams = { language: 'sv' };
await waitForLogoutToTimeout(mockedWindowControls, logoutParams);
expect(signoutRedirectSpy).toHaveBeenCalledTimes(1);
expect(signoutRedirectSpy).toHaveBeenNthCalledWith(1, {
extraQueryParams: {
ui_locales: loginParams.language,
ui_locales: logoutParams.language,
},
});
await waitFor(() => {
expect(mockedWindowControls.getCallParameters().get('ui_locales')).toBe(loginParams.language);
expect(mockedWindowControls.getCallParameters().get('ui_locales')).toBe(logoutParams.language);
});
expect(oidcClient.isAuthenticated()).toBeFalsy();
});
it('should pass other LogoutProps than "language" to signoutRedirect and convert "language" to an extraQueryParam', async () => {
const { userManager } = testData;
const signoutRedirectSpy = jest.spyOn(userManager, 'signoutRedirect');
const loginParams: LogoutProps = {
const logoutParams: LogoutProps = {
extraQueryParams: { extraParam1: 'extra' },
state: { stateValue: 2, path: '/logout' },
id_token_hint: 'id_token_hint',
post_logout_redirect_uri: 'post_logout_redirect_uri',
};
await waitForLogoutToTimeout({ ...loginParams, language: 'sv' });
await waitForLogoutToTimeout(mockedWindowControls, { ...logoutParams, language: 'sv' });
expect(signoutRedirectSpy).toHaveBeenNthCalledWith(1, {
...loginParams,
...logoutParams,
extraQueryParams: {
...loginParams.extraQueryParams,
...logoutParams.extraQueryParams,
ui_locales: 'sv',
},
});
Expand All @@ -158,7 +158,7 @@ describe('oidcClient', () => {
const userManagerProps = getDefaultOidcClientTestProps().userManagerSettings as UserManagerSettings;
const userFromStorage = getUserFromStorage(userManagerProps);
expect(userFromStorage).not.toBeNull();
await waitForLogoutToTimeout();
await waitForLogoutToTimeout(mockedWindowControls);
const userFromStorageAfterLogout = getUserFromStorage(userManagerProps);
expect(userFromStorageAfterLogout).toBeNull();
});
Expand Down Expand Up @@ -445,9 +445,9 @@ describe('oidcClient', () => {
});
it('state changes when login is called. Payload has the state change', async () => {
await initTests({ modules: [listenerModule] });
await waitForLoginToTimeout();
await waitForLoginToTimeout(mockedWindowControls);
const emittedSignals = getListenerSignals(listenerModule.getListener());
expect(emittedSignals).toHaveLength(1);
expect(emittedSignals.length > 0).toBeTruthy();
expect(emittedSignals[0].type).toBe(stateChangeSignalType);
expect(emittedSignals[0].payload).toMatchObject({
state: oidcClientStates.LOGGING_IN,
Expand All @@ -457,7 +457,7 @@ describe('oidcClient', () => {
it('state changes when logout is called. Payload has the state change. USER_REMOVED event is emitted.', async () => {
placeUserToStorage();
await initTests({ modules: [listenerModule] });
await waitForLogoutToTimeout();
await waitForLogoutToTimeout(mockedWindowControls);
const emittedSignals = getListenerSignals(listenerModule.getListener());
expect(emittedSignals).toHaveLength(2);
expect(emittedSignals[0].type).toBe(stateChangeSignalType);
Expand Down
51 changes: 18 additions & 33 deletions packages/react/src/components/login/testUtils/oidcClientTestUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { createOidcClient } from '../client/oidcClient';
import openIdConfiguration from '../__mocks__/openIdConfiguration.json';
import { UserCreationProps, createSignInResponse, createUserAndPlaceUserToStorage } from './userTestUtil';
import { Beacon, ConnectedModule, createBeacon } from '../beacon/beacon';
// eslint-disable-next-line jest/no-mocks-import
import { MockedWindowLocationActions } from '../__mocks__/mockWindowLocation';

export type InitTestResult = {
oidcClient: OidcClient;
Expand Down Expand Up @@ -82,41 +84,24 @@ export function createOidcClientTestSuite() {
return Promise.reject(new Error(`Unknown url ${req.url}`));
});

// oidcClient.login redirects the browser.
// The returned promise is never resolved - unless an error occurs.
// Always reject it here, no need for both fulfillments.
async function waitForLoginToTimeout(loginProps?: LoginProps) {
let promise: Promise<void>;
await expect(async () =>
waitFor(
() => {
if (!promise) {
promise = oidcClient.login(loginProps);
}
return Promise.reject(new Error('Login redirected'));
},
{
timeout: 1000,
},
),
).rejects.toThrow();
async function waitForLoginToTimeout(mockedWindowControls: MockedWindowLocationActions, loginProps?: LoginProps) {
const currentCallCount = mockedWindowControls.getCalls().length;
oidcClient.login(loginProps).then(jest.fn()).catch(jest.fn());
await waitFor(() => {
if (mockedWindowControls.getCalls().length === currentCallCount) {
throw new Error('mockedWindowControls not called yet.');
}
});
}

// loginClient.logout redirects the browser.
// The returned promise is never resolved.
async function waitForLogoutToTimeout(logoutProps?: LogoutProps) {
let promise: Promise<void>;
await expect(() =>
waitFor(
() => {
if (!promise) {
promise = oidcClient.logout(logoutProps);
}
return promise;
},
{ timeout: 1000 },
),
).rejects.toThrow();
async function waitForLogoutToTimeout(mockedWindowControls: MockedWindowLocationActions, logoutProps?: LogoutProps) {
const currentCallCount = mockedWindowControls.getCalls().length;
oidcClient.logout(logoutProps).then(jest.fn()).catch(jest.fn());
await waitFor(() => {
if (mockedWindowControls.getCalls().length === currentCallCount) {
throw new Error('mockedWindowControls not called yet.');
}
});
}

const initTests = async (
Expand Down
40 changes: 22 additions & 18 deletions packages/react/src/components/login/whole.setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
StateChangeSignalPayload,
stateChangeSignalType,
waitForSignals,
errorSignalType,
} from './beacon/signals';
import { LISTEN_TO_ALL_MARKER, SignalNamespace, createBeacon } from './beacon/beacon';
import { ApiTokenClientProps, TokenData, apiTokensClientEvents, apiTokensClientNamespace } from './apiTokensClient';
Expand All @@ -47,6 +46,7 @@ type TestScenarioProps = {
userInStorageType?: UserInScenarios;
signInResponseType?: HttpStatusCode;
renewResponseType?: HttpStatusCode;
openIdConfigResponseType?: HttpStatusCode;
};
apiTokensClientProps: {
tokensInStorage?: ApiTokensInScenarios;
Expand Down Expand Up @@ -133,15 +133,6 @@ describe('Test all modules together', () => {
const openIdResponder: Responder = {
id: 'openIdConfig',
path: '/openid-configuration',
responses: [
{
status: HttpStatusCode.OK,
body: JSON.stringify(openIdConfiguration),
headers: {
'content-type': 'application/json',
},
},
],
};
const responders: Responder[] = [apiTokensResponder, sessionPollerResponder, openIdResponder];

Expand All @@ -151,6 +142,7 @@ describe('Test all modules together', () => {
userInStorageType: 'none',
signInResponseType: HttpStatusCode.OK,
renewResponseType: HttpStatusCode.OK,
openIdConfigResponseType: HttpStatusCode.OK,
};
const defaultApiTokensClientProps: TestScenarioProps['apiTokensClientProps'] = {
tokensInStorage: 'none',
Expand Down Expand Up @@ -188,6 +180,21 @@ describe('Test all modules together', () => {
return null;
};

const setupOpenIdConfigResponse = (props: TestScenarioProps) => {
const { oidcClientProps } = props;
if (oidcClientProps.openIdConfigResponseType === HttpStatusCode.OK) {
const response = {
status: HttpStatusCode.OK,
body: JSON.stringify(openIdConfiguration),
headers: {
'content-type': 'application/json',
},
};
addResponse(response, openIdResponder.id);
}
return null;
};

const setupSignInResponse = (props: TestScenarioProps): SigninResponse | null => {
const { oidcClientProps } = props;
if (oidcClientProps.signInResponseType === HttpStatusCode.OK) {
Expand Down Expand Up @@ -247,6 +254,7 @@ describe('Test all modules together', () => {
const initialUser = setupInitialUser(testProps);

const { oidcClient, userManager } = await initTests({});
setupOpenIdConfigResponse(testProps);
setupSignInResponse(testProps);
setupUserRenewalResponse(testProps, userManager);

Expand Down Expand Up @@ -362,19 +370,15 @@ describe('Test all modules together', () => {
});
it('When login starts, only login process starts', async () => {
const { getReceivedSignalTypes } = await initAll({});
await waitForLoginToTimeout();
await waitForLoginToTimeout(mockedWindowControls);
// open id config is called on every login
jest.advanceTimersByTime(1000000);
expect(getRequestCount()).toBe(1);
expect(getRequestsInfoById(openIdResponder.id as string)).toHaveLength(1);
expect(getReceivedSignalTypes(oidcClientNamespace)).toEqual([
initSignalType,
oidcClientStates.LOGGING_IN,
errorSignalType,
]);
expect(getReceivedSignalTypes(oidcClientNamespace)).toEqual([initSignalType, oidcClientStates.LOGGING_IN]);
expect(getReceivedSignalTypes(apiTokensClientNamespace)).toEqual([initSignalType]);
expect(getReceivedSignalTypes(sessionPollerNamespace)).toEqual([initSignalType]);
expect(getReceivedSignalTypes(LISTEN_TO_ALL_MARKER)).toHaveLength(8);
expect(getReceivedSignalTypes(LISTEN_TO_ALL_MARKER)).toHaveLength(7);
});
it('When login handleCallback is called and finished, apiTokens are fetched and session polling starts', async () => {
const { getReceivedSignalTypes, oidcClient, beacon } = await initAll({});
Expand Down Expand Up @@ -517,7 +521,7 @@ describe('Test all modules together', () => {
});
expect(mockMapForSessionHttpPoller.getCalls('start')).toHaveLength(1);
expect(mockMapForSessionHttpPoller.getCalls('stop')).toHaveLength(0);
await waitForLogoutToTimeout();
await waitForLogoutToTimeout(mockedWindowControls);
expect(getReceivedSignalTypes(oidcClientNamespace)).toEqual([
initSignalType,
oidcClientStates.LOGGING_OUT,
Expand Down

0 comments on commit 32f41bd

Please sign in to comment.