Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: AuthPluginParent wasn't working when embedded in an iframe #1383

Merged
merged 2 commits into from
Jun 21, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion packages/app-utils/src/components/AuthBootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ export type AuthBootstrapProps = {

/** Core auth plugins that are always loaded */
const CORE_AUTH_PLUGINS = new Map([
['@deephaven/auth-plugins.AuthPluginPsk', AuthPluginPsk],
['@deephaven/auth-plugins.AuthPluginParent', AuthPluginParent],
['@deephaven/auth-plugins.AuthPluginPsk', AuthPluginPsk],
['@deephaven/auth-plugins.AuthPluginAnonymous', AuthPluginAnonymous],
]);

Expand Down
33 changes: 30 additions & 3 deletions packages/auth-plugins/src/AuthPluginParent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { act, render, screen } from '@testing-library/react';
import { ApiContext, ClientContext } from '@deephaven/jsapi-bootstrap';
import { dh } from '@deephaven/jsapi-shim';
import type { CoreClient, LoginOptions } from '@deephaven/jsapi-types';
import { TestUtils } from '@deephaven/utils';
import AuthPluginParent from './AuthPluginParent';
import { AuthConfigMap } from './AuthPlugin';

let mockParentResponse: Promise<LoginOptions>;
jest.mock('@deephaven/jsapi-utils', () => ({
...jest.requireActual('@deephaven/jsapi-utils'),
LOGIN_OPTIONS_REQUEST: 'mock-login-options-request',
requestParentResponse: jest.fn(() => mockParentResponse),
}));
Expand Down Expand Up @@ -49,8 +51,29 @@ function renderComponent(

describe('availability tests', () => {
const authHandlers = [];

it('is available when window opener is set', () => {
window.opener = { postMessage: jest.fn() };
const oldWindowOpener = window.opener;
// Can't use a spy because window.opener isn't set by default
// Still using a var to set the old value, in case that behaviour ever changes
window.opener = TestUtils.createMockProxy<Window>({
postMessage: jest.fn(),
});
window.history.pushState(
{},
'Test Title',
`/test.html?authProvider=parent`
);
expect(AuthPluginParent.isAvailable(authHandlers, authConfigMap)).toBe(
true
);
window.opener = oldWindowOpener;
});

it('is available when window parent is set', () => {
const parentSpy = jest.spyOn(window, 'parent', 'get').mockReturnValue(
TestUtils.createMockProxy<Window>({ postMessage: jest.fn() })
);
window.history.pushState(
{},
'Test Title',
Expand All @@ -59,12 +82,16 @@ describe('availability tests', () => {
expect(AuthPluginParent.isAvailable(authHandlers, authConfigMap)).toBe(
true
);
parentSpy.mockRestore();
});
it('is not available when window opener not set', () => {
delete window.opener;

it('is not available when window opener and parent are not set', () => {
const oldWindowOpener = window.opener;
window.opener = null;
expect(AuthPluginParent.isAvailable(authHandlers, authConfigMap)).toBe(
false
);
window.opener = oldWindowOpener;
});
});

Expand Down
3 changes: 2 additions & 1 deletion packages/auth-plugins/src/AuthPluginParent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import type { LoginOptions } from '@deephaven/jsapi-types';
import {
getWindowParent,
LOGIN_OPTIONS_REQUEST,
requestParentResponse,
} from '@deephaven/jsapi-utils';
Expand Down Expand Up @@ -49,7 +50,7 @@ function Component({ children }: AuthPluginProps): JSX.Element {
const AuthPluginParent: AuthPlugin = {
Component,
isAvailable: () =>
window.opener != null && getWindowAuthProvider() === 'parent',
getWindowParent() != null && getWindowAuthProvider() === 'parent',
};

export default AuthPluginParent;
80 changes: 78 additions & 2 deletions packages/jsapi-utils/src/MessageUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TestUtils } from '@deephaven/utils';
import {
makeMessage,
makeResponse,
Expand All @@ -7,11 +8,11 @@ import {

it('Throws an exception if called on a window without parent', async () => {
await expect(requestParentResponse('request')).rejects.toThrow(
'window.opener is null, unable to send request.'
'window parent is null, unable to send request.'
);
});

describe('requestParentResponse', () => {
describe('requestParentResponse with opener', () => {
let addListenerSpy: jest.SpyInstance;
let removeListenerSpy: jest.SpyInstance;
let listenerCallback;
Expand Down Expand Up @@ -80,3 +81,78 @@ describe('requestParentResponse', () => {
jest.useRealTimers();
});
});

describe('requestParentResponse with parent', () => {
let addListenerSpy: jest.SpyInstance;
let removeListenerSpy: jest.SpyInstance;
let windowParentSpy: jest.SpyInstance;
let listenerCallback;
let messageId;
const mockPostMessage = jest.fn((data: Message<unknown>) => {
messageId = data.id;
});
beforeEach(() => {
addListenerSpy = jest
.spyOn(window, 'addEventListener')
.mockImplementation((event, cb) => {
listenerCallback = cb;
});
removeListenerSpy = jest.spyOn(window, 'removeEventListener');
windowParentSpy = jest.spyOn(window, 'parent', 'get').mockReturnValue(
TestUtils.createMockProxy<Window>({
postMessage: mockPostMessage,
})
);
});

afterEach(() => {
addListenerSpy.mockRestore();
removeListenerSpy.mockRestore();
windowParentSpy.mockRestore();
mockPostMessage.mockClear();
messageId = undefined;
});

it('Posts message to parent and subscribes to response', async () => {
mofojed marked this conversation as resolved.
Show resolved Hide resolved
requestParentResponse('request');
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining(makeMessage('request', messageId)),
'*'
);
expect(addListenerSpy).toHaveBeenCalledWith(
'message',
expect.any(Function)
);
});

it('Resolves with the payload from the parent window response and unsubscribes', async () => {
const PAYLOAD = 'PAYLOAD';
const promise = requestParentResponse('request');
listenerCallback({
data: makeResponse(messageId, PAYLOAD),
});
const result = await promise;
expect(result).toBe(PAYLOAD);
expect(removeListenerSpy).toHaveBeenCalledWith('message', listenerCallback);
});

it('Ignores unrelated response, rejects on timeout', async () => {
jest.useFakeTimers();
const promise = requestParentResponse('request');
listenerCallback({
data: makeMessage('wrong-id'),
});
jest.runOnlyPendingTimers();
await expect(promise).rejects.toThrow('Request timed out');
jest.useRealTimers();
});

it('Times out if no response', async () => {
jest.useFakeTimers();
const promise = requestParentResponse('request');
jest.runOnlyPendingTimers();
expect(removeListenerSpy).toHaveBeenCalled();
await expect(promise).rejects.toThrow('Request timed out');
jest.useRealTimers();
});
});
17 changes: 14 additions & 3 deletions packages/jsapi-utils/src/MessageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ export function makeResponse<T>(messageId: string, payload: T): Response<T> {
return { id: messageId, payload };
}

export function getWindowParent(): Window | null {
if (window.opener != null) {
return window.opener;
}
if (window.parent != null && window.parent !== window) {
return window.parent;
}
return null;
}

/**
* Request data from the parent window and wait for response
* @param request Request message to send to the parent window
Expand All @@ -105,8 +115,9 @@ export async function requestParentResponse(
request: string,
timeout = 30000
): Promise<unknown> {
if (window.opener == null) {
throw new Error('window.opener is null, unable to send request.');
const parent = getWindowParent();
if (parent == null) {
throw new Error('window parent is null, unable to send request.');
}
return new Promise((resolve, reject) => {
let timeoutId: number;
Expand All @@ -131,6 +142,6 @@ export async function requestParentResponse(
window.removeEventListener('message', listener);
reject(new TimeoutError('Request timed out'));
}, timeout);
window.opener.postMessage(makeMessage(request, id), '*');
parent.postMessage(makeMessage(request, id), '*');
});
}