From e7a94bb93295d23226ebb97c435bdd6310f9fe07 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Fri, 30 Apr 2021 11:19:39 -0400 Subject: [PATCH] Reduce security plugin page load bundle (#98819) # Conflicts: # packages/kbn-optimizer/limits.yml --- packages/kbn-optimizer/limits.yml | 4 +- .../capture_url/capture_url_app.ts | 4 +- .../public/session/session_timeout.test.tsx | 86 ++++++++++++++----- .../public/session/session_timeout.tsx | 33 ++++--- 4 files changed, 90 insertions(+), 37 deletions(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 5e671cbf9fbdd..60872aac96f4a 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -67,12 +67,12 @@ pageLoadAssetSize: savedObjectsTagging: 59482 savedObjectsTaggingOss: 20590 searchprofiler: 67080 - security: 189428 + security: 95864 securityOss: 30806 securitySolution: 187863 share: 99205 snapshotRestore: 79176 - spaces: 389643 + spaces: 57868 telemetry: 51957 telemetryManagementSection: 38586 tileMap: 65337 diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts index af45314c5bacb..97bbd0848e9c4 100644 --- a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts @@ -8,7 +8,6 @@ import type { ApplicationSetup, FatalErrorsSetup, HttpSetup } from 'src/core/public'; import { AUTH_URL_HASH_QUERY_STRING_PARAMETER } from '../../../common/constants'; -import { parseNext } from '../../../common/parse_next'; interface CreateDeps { application: ApplicationSetup; @@ -46,6 +45,9 @@ export const captureURLApp = Object.freeze({ appRoute: '/internal/security/capture-url', async mount() { try { + // This is an async import because it requires `url`, which is a sizable dependency. + // Otherwise this becomes part of the "page load bundle". + const { parseNext } = await import('../../../common/parse_next'); const url = new URL( parseNext(window.location.href, http.basePath.serverBasePath), window.location.origin diff --git a/x-pack/plugins/security/public/session/session_timeout.test.tsx b/x-pack/plugins/security/public/session/session_timeout.test.tsx index d224edb8cafd4..1faa105691259 100644 --- a/x-pack/plugins/security/public/session/session_timeout.test.tsx +++ b/x-pack/plugins/security/public/session/session_timeout.test.tsx @@ -7,7 +7,7 @@ import BroadcastChannel from 'broadcast-channel'; -import { mountWithIntl } from '@kbn/test/jest'; +import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { coreMock } from 'src/core/public/mocks'; import { createSessionExpiredMock } from './session_expired.mock'; @@ -112,6 +112,7 @@ describe('Session Timeout', () => { afterEach(async () => { jest.clearAllMocks(); + jest.unmock('broadcast-channel'); sessionTimeout.stop(); }); @@ -122,22 +123,42 @@ describe('Session Timeout', () => { describe('Lifecycle', () => { test(`starts and initializes on a non-anonymous path`, async () => { - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); // eslint-disable-next-line dot-notation expect(sessionTimeout['channel']).not.toBeUndefined(); expect(http.fetch).toHaveBeenCalledTimes(1); }); + test(`starts and initializes if the broadcast channel fails to load`, async () => { + jest.mock('broadcast-channel', () => { + throw new Error('Unable to load broadcast channel!'); + }); + const consoleSpy = jest.spyOn(console, 'warn'); + + sessionTimeout.start(); + await nextTick(); + // eslint-disable-next-line dot-notation + expect(sessionTimeout['channel']).toBeUndefined(); + expect(http.fetch).toHaveBeenCalledTimes(1); + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy.mock.calls[0][0]).toMatchInlineSnapshot( + `"Failed to load broadcast channel. Session management will not be kept in sync when multiple tabs are loaded."` + ); + }); + test(`starts and does not initialize on an anonymous path`, async () => { http.anonymousPaths.isAnonymous.mockReturnValue(true); - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); // eslint-disable-next-line dot-notation expect(sessionTimeout['channel']).toBeUndefined(); expect(http.fetch).not.toHaveBeenCalled(); }); test(`stops`, async () => { - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); // eslint-disable-next-line dot-notation const close = jest.fn(sessionTimeout['channel']!.close); // eslint-disable-next-line dot-notation @@ -157,7 +178,8 @@ describe('Session Timeout', () => { ...defaultSessionInfo, idleTimeoutExpiration: now + 5_000_000_000, }); - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); // Advance timers far enough to call intermediate `setTimeout` multiple times, but before any // of the timers is supposed to be triggered. @@ -184,7 +206,8 @@ describe('Session Timeout', () => { }); test(`handles success`, async () => { - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); expect(http.fetch).toHaveBeenCalledTimes(1); // eslint-disable-next-line dot-notation @@ -195,7 +218,8 @@ describe('Session Timeout', () => { test(`handles error`, async () => { const mockErrorResponse = new Error('some-error'); http.fetch.mockRejectedValue(mockErrorResponse); - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); expect(http.fetch).toHaveBeenCalledTimes(1); // eslint-disable-next-line dot-notation @@ -206,7 +230,8 @@ describe('Session Timeout', () => { describe('warning toast', () => { test(`shows idle timeout warning toast`, async () => { - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); // we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires jest.advanceTimersByTime(55 * 1000); @@ -218,7 +243,8 @@ describe('Session Timeout', () => { ...defaultSessionInfo, idleTimeoutExpiration: now + 5_000_000_000, }); - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); // we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires jest.advanceTimersByTime(5_000_000_000 - 66 * 1000); @@ -236,7 +262,8 @@ describe('Session Timeout', () => { provider: { type: 'basic', name: 'basic1' }, }; http.fetch.mockResolvedValue(sessionInfo); - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); // we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires jest.advanceTimersByTime(55 * 1000); @@ -250,7 +277,8 @@ describe('Session Timeout', () => { lifespanExpiration: now + 5_000_000_000, }; http.fetch.mockResolvedValue(sessionInfo); - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); // we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires jest.advanceTimersByTime(5_000_000_000 - 66 * 1000); @@ -261,7 +289,8 @@ describe('Session Timeout', () => { }); test(`extend only results in an HTTP call if a warning is shown`, async () => { - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); expect(http.fetch).toHaveBeenCalledTimes(1); await sessionTimeout.extend('/foo'); @@ -287,7 +316,8 @@ describe('Session Timeout', () => { provider: { type: 'basic', name: 'basic1' }, }; http.fetch.mockResolvedValue(sessionInfo); - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); // we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires jest.advanceTimersByTime(55 * 1000); @@ -299,7 +329,8 @@ describe('Session Timeout', () => { }); test(`extend hides displayed warning toast`, async () => { - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); expect(http.fetch).toHaveBeenCalledTimes(1); // we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires @@ -319,7 +350,8 @@ describe('Session Timeout', () => { }); test(`extend does nothing for session-related routes`, async () => { - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); expect(http.fetch).toHaveBeenCalledTimes(1); // we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires @@ -333,7 +365,8 @@ describe('Session Timeout', () => { }); test(`checks for updated session info before the warning displays`, async () => { - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); expect(http.fetch).toHaveBeenCalledTimes(1); // we check for updated session info 1 second before the warning is shown @@ -343,7 +376,8 @@ describe('Session Timeout', () => { }); test('clicking "extend" causes a new HTTP request (which implicitly extends the session)', async () => { - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); expect(http.fetch).toHaveBeenCalledTimes(1); // we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires @@ -366,7 +400,8 @@ describe('Session Timeout', () => { lifespanExpiration: null, provider: { type: 'basic', name: 'basic1' }, }); - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); expect(http.fetch).toHaveBeenCalled(); jest.advanceTimersByTime(0); @@ -376,7 +411,8 @@ describe('Session Timeout', () => { describe('session expiration', () => { test(`expires the session 5 seconds before it really expires`, async () => { - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); jest.advanceTimersByTime(114 * 1000); expect(sessionExpired.logout).not.toHaveBeenCalled(); @@ -391,7 +427,8 @@ describe('Session Timeout', () => { idleTimeoutExpiration: now + 5_000_000_000, }); - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); jest.advanceTimersByTime(5_000_000_000 - 6000); expect(sessionExpired.logout).not.toHaveBeenCalled(); @@ -401,7 +438,8 @@ describe('Session Timeout', () => { }); test(`extend delays the expiration`, async () => { - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); expect(http.fetch).toHaveBeenCalledTimes(1); const elapsed = 114 * 1000; @@ -438,7 +476,8 @@ describe('Session Timeout', () => { lifespanExpiration: null, provider: { type: 'basic', name: 'basic1' }, }); - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); jest.advanceTimersByTime(0); expect(sessionExpired.logout).toHaveBeenCalled(); @@ -446,7 +485,8 @@ describe('Session Timeout', () => { test(`'null' sessionTimeout never logs you out`, async () => { http.fetch.mockResolvedValue({ now, idleTimeoutExpiration: null, lifespanExpiration: null }); - await sessionTimeout.start(); + sessionTimeout.start(); + await nextTick(); jest.advanceTimersByTime(Number.MAX_VALUE); expect(sessionExpired.logout).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/public/session/session_timeout.tsx b/x-pack/plugins/security/public/session/session_timeout.tsx index cc7eaa551b1b3..2288fce8d30af 100644 --- a/x-pack/plugins/security/public/session/session_timeout.tsx +++ b/x-pack/plugins/security/public/session/session_timeout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { BroadcastChannel } from 'broadcast-channel'; +import type { BroadcastChannel as BroadcastChannelType } from 'broadcast-channel'; import type { HttpSetup, NotificationsSetup, Toast, ToastInput } from 'src/core/public'; @@ -45,7 +45,7 @@ export interface ISessionTimeout { } export class SessionTimeout implements ISessionTimeout { - private channel?: BroadcastChannel; + private channel?: BroadcastChannelType; private sessionInfo?: SessionInfo; private fetchTimer?: number; private warningTimer?: number; @@ -64,15 +64,26 @@ export class SessionTimeout implements ISessionTimeout { return; } - // subscribe to a broadcast channel for session timeout messages - // this allows us to synchronize the UX across tabs and avoid repetitive API calls - const name = `${this.tenant}/session_timeout`; - this.channel = new BroadcastChannel(name, { webWorkerSupport: false }); - this.channel.onmessage = this.handleSessionInfoAndResetTimers; - - // Triggers an initial call to the endpoint to get session info; - // when that returns, it will set the timeout - return this.fetchSessionInfoAndResetTimers(); + import('broadcast-channel') + .then(({ BroadcastChannel }) => { + // subscribe to a broadcast channel for session timeout messages + // this allows us to synchronize the UX across tabs and avoid repetitive API calls + const name = `${this.tenant}/session_timeout`; + this.channel = new BroadcastChannel(name, { webWorkerSupport: false }); + this.channel.onmessage = this.handleSessionInfoAndResetTimers; + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.warn( + `Failed to load broadcast channel. Session management will not be kept in sync when multiple tabs are loaded.`, + e + ); + }) + .finally(() => { + // Triggers an initial call to the endpoint to get session info; + // when that returns, it will set the timeout + return this.fetchSessionInfoAndResetTimers(); + }); } stop() {