diff --git a/package.json b/package.json index 942a4d3645..48b1128197 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "@teamsupercell/typings-for-css-modules-loader": "^2.4.0", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^13.3.0", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.4.3", "@types/classnames": "^2.2.11", "@types/d3": "^7.4.0", diff --git a/redisinsight/ui/src/components/main-router/MainRouter.spec.tsx b/redisinsight/ui/src/components/main-router/MainRouter.spec.tsx index c6d8c7b6bb..b0fe3cb0b2 100644 --- a/redisinsight/ui/src/components/main-router/MainRouter.spec.tsx +++ b/redisinsight/ui/src/components/main-router/MainRouter.spec.tsx @@ -1,6 +1,7 @@ import React from 'react' import reactRouterDom from 'react-router-dom' import { cloneDeep } from 'lodash' +import { waitFor } from '@testing-library/react' import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' import Router from 'uiSrc/Router' import { localStorageService } from 'uiSrc/services' @@ -8,7 +9,7 @@ import { Pages } from 'uiSrc/constants' import { appContextSelector, setCurrentWorkspace } from 'uiSrc/slices/app/context' import { AppWorkspace } from 'uiSrc/slices/interfaces' import MainRouter from './MainRouter' -import * as activityMonitor from './activityMonitor' +import * as activityMonitor from './hooks/useActivityMonitor' jest.mock('uiSrc/services', () => ({ ...jest.requireActual('uiSrc/services'), @@ -67,14 +68,13 @@ describe('MainRouter', () => { expect(store.getActions()).toContainEqual(setCurrentWorkspace(AppWorkspace.Databases)) }) - it('starts activity monitor on mount and stops on unmount', () => { - const startActivityMonitorSpy = jest.spyOn(activityMonitor, 'startActivityMonitor') - const stopActivityMonitorSpy = jest.spyOn(activityMonitor, 'stopActivityMonitor') + it('starts activity monitor on mount and stops on unmount', async () => { + const useActivityMonitorSpy = jest.spyOn(activityMonitor, 'useActivityMonitor') - const { unmount } = render() + render() - expect(startActivityMonitorSpy).toHaveBeenCalledTimes(1) - unmount() - expect(stopActivityMonitorSpy).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(useActivityMonitorSpy).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/redisinsight/ui/src/components/main-router/MainRouter.tsx b/redisinsight/ui/src/components/main-router/MainRouter.tsx index 02b92c5bb9..3bbd107112 100644 --- a/redisinsight/ui/src/components/main-router/MainRouter.tsx +++ b/redisinsight/ui/src/components/main-router/MainRouter.tsx @@ -18,7 +18,7 @@ import { AppWorkspace } from 'uiSrc/slices/interfaces' import SuspenseLoader from 'uiSrc/components/main-router/components/SuspenseLoader' import RedisStackRoutes from './components/RedisStackRoutes' import DEFAULT_ROUTES from './constants/defaultRoutes' -import { startActivityMonitor, stopActivityMonitor } from './activityMonitor' +import { useActivityMonitor } from './hooks/useActivityMonitor' const MainRouter = () => { const { server } = useSelector(appInfoSelector) @@ -28,6 +28,7 @@ const MainRouter = () => { const dispatch = useDispatch() const history = useHistory() const { pathname } = useLocation() + useActivityMonitor() const isRedisStack = server?.buildType === BuildType.RedisStack @@ -38,13 +39,6 @@ const MainRouter = () => { history.push(Pages.rdi) } } - - // notify parent window of last activity - startActivityMonitor() - - return () => { - stopActivityMonitor() - } }, []) useEffect(() => { diff --git a/redisinsight/ui/src/components/main-router/activityMonitor.ts b/redisinsight/ui/src/components/main-router/activityMonitor.ts deleted file mode 100644 index 229e7b4c62..0000000000 --- a/redisinsight/ui/src/components/main-router/activityMonitor.ts +++ /dev/null @@ -1,42 +0,0 @@ -import throttle from 'lodash/throttle' -import { getConfig } from 'uiSrc/config' - -const riConfig = getConfig() - -const throttleTimeout = riConfig.app.activityMonitorThrottleTimeout -const windowEvents = ['click', 'keydown', 'scroll', 'touchstart'] - -const getIsMonitorEnabled = () => riConfig.app.activityMonitorOrigin && window.opener - -const onActivity = throttle(() => { - try { - // post event to parent window - window.opener.postMessage({ name: 'setLastActivity' }, riConfig.app.activityMonitorOrigin) - } catch { - // ignore errors - } -}, throttleTimeout) - -export const startActivityMonitor = () => { - try { - if (getIsMonitorEnabled()) { - windowEvents.forEach((event) => { - window.addEventListener(event, onActivity, { passive: true, capture: true }) - }) - } - } catch { - // ignore errors - } -} - -export const stopActivityMonitor = () => { - try { - if (getIsMonitorEnabled()) { - windowEvents.forEach((event) => { - window.removeEventListener(event, onActivity, { capture: true }) - }) - } - } catch { - // ignore errors - } -} diff --git a/redisinsight/ui/src/components/main-router/activityMonitor.spec.ts b/redisinsight/ui/src/components/main-router/hooks/useActivityMonitor.spec.ts similarity index 53% rename from redisinsight/ui/src/components/main-router/activityMonitor.spec.ts rename to redisinsight/ui/src/components/main-router/hooks/useActivityMonitor.spec.ts index 2dd6a3d12c..c07651ec91 100644 --- a/redisinsight/ui/src/components/main-router/activityMonitor.spec.ts +++ b/redisinsight/ui/src/components/main-router/hooks/useActivityMonitor.spec.ts @@ -1,7 +1,8 @@ import set from 'lodash/set' -import { waitFor } from '@testing-library/react' -import { startActivityMonitor, stopActivityMonitor } from 'uiSrc/components/main-router/activityMonitor' +import { renderHook } from '@testing-library/react-hooks' import { getConfig } from 'uiSrc/config' +import { mockWindowLocation } from 'uiSrc/utils/test-utils' +import { useActivityMonitor } from './useActivityMonitor' const addEventListenerSpy = jest.spyOn(window, 'addEventListener') const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener') @@ -21,22 +22,38 @@ const mockWindowOpener = (postMessage = jest.fn()) => { } } +jest.useFakeTimers() + +const browserUrl = 'http://localhost/123/browser' +const logoutUrl = 'http://localhost/#/logout' + +let setHrefMock: typeof jest.fn beforeEach(() => { jest.resetAllMocks() + + const mockDate = new Date('2024-11-22T12:00:00Z') + jest.setSystemTime(mockDate) + mockConfig() + setHrefMock = mockWindowLocation(browserUrl) mockWindowOpener() }) -describe('Activity monitor', () => { - it('should start and stop activity monitor if window.opener and monitor origin are defined', () => { - startActivityMonitor() +describe('useActivityMonitor', () => { + it('should register event handlers on mount and unregister on unmount', () => { + const { unmount } = renderHook(useActivityMonitor) + + // Verify mount behavior expect(addEventListenerSpy).toHaveBeenCalledTimes(4) expect(addEventListenerSpy).toHaveBeenNthCalledWith(1, 'click', expect.any(Function), addEventListenerProps) expect(addEventListenerSpy).toHaveBeenNthCalledWith(2, 'keydown', expect.any(Function), addEventListenerProps) expect(addEventListenerSpy).toHaveBeenNthCalledWith(3, 'scroll', expect.any(Function), addEventListenerProps) expect(addEventListenerSpy).toHaveBeenNthCalledWith(4, 'touchstart', expect.any(Function), addEventListenerProps) - stopActivityMonitor() + // Trigger unmount + unmount() + + // Verify unmount behavior expect(removeEventListenerSpy).toHaveBeenCalledTimes(4) expect(removeEventListenerSpy).toHaveBeenNthCalledWith(1, 'click', expect.any(Function), removeEventListenerProps) expect(removeEventListenerSpy).toHaveBeenNthCalledWith(2, 'keydown', expect.any(Function), removeEventListenerProps) @@ -44,44 +61,97 @@ describe('Activity monitor', () => { expect(removeEventListenerSpy).toHaveBeenNthCalledWith(4, 'touchstart', expect.any(Function), removeEventListenerProps) }) - it('should not start or stop activity monitor if window.opener is undefined', () => { + it('should register event handlers even if window.opener is undefined', () => { global.window.opener = undefined - startActivityMonitor() - stopActivityMonitor() + const { unmount } = renderHook(useActivityMonitor) - expect(addEventListenerSpy).not.toHaveBeenCalled() - expect(removeEventListenerSpy).not.toHaveBeenCalled() + // Verify mount behavior + expect(addEventListenerSpy).toHaveBeenCalledTimes(4) + + // Trigger unmount + unmount() + + // Verify unmount behavior + expect(removeEventListenerSpy).toHaveBeenCalledTimes(4) }) - it('should not start or stop activity monitor if monitor origin is falsey', () => { + it('should not register handlers if activityMonitorOrigin is not defined', () => { mockConfig('') - startActivityMonitor() - stopActivityMonitor() + const { unmount } = renderHook(useActivityMonitor) + // Verify mount behavior expect(addEventListenerSpy).not.toHaveBeenCalled() + + // Trigger unmount + unmount() + + // Verify unmount behavior expect(removeEventListenerSpy).not.toHaveBeenCalled() }) + it('should logout user after expected amount of inactivity', async () => { + renderHook(useActivityMonitor) + jest.advanceTimersByTime(1900 * 1000) + expect(setHrefMock).toHaveBeenCalledWith(logoutUrl) + }) + + it('should not logout user if hook unmounts', async () => { + const { unmount } = renderHook(useActivityMonitor) + jest.advanceTimersByTime(1700 * 1000) + expect(setHrefMock).not.toHaveBeenCalled() + + unmount() + + jest.advanceTimersByTime(1000 * 1000) + expect(setHrefMock).not.toHaveBeenCalled() + }) + + it('should keep user logged in if they stay active', async () => { + renderHook(useActivityMonitor) + + const activityHandler = addEventListenerSpy.mock.calls[0]?.[1] as Function + + // act + jest.advanceTimersByTime(1700 * 1000) + activityHandler() + jest.advanceTimersByTime(1700 * 1000) + + // assert + expect(setHrefMock).not.toHaveBeenCalled() + + // act + activityHandler() + jest.advanceTimersByTime(1700 * 1000) + + // assert + expect(setHrefMock).not.toHaveBeenCalled() + + // act + jest.advanceTimersByTime(1000 * 1000) + + // assert + expect(setHrefMock).toHaveBeenCalledWith(logoutUrl) + }) + it('should throttle events and call window.opener.postMessage', async () => { const mockPostMessage = jest.fn() mockWindowOpener(mockPostMessage) - startActivityMonitor() + renderHook(useActivityMonitor) - expect(addEventListenerSpy).toHaveBeenCalledTimes(4) - - // simulate events + // act const activityHandler = addEventListenerSpy.mock.calls[0]?.[1] as Function activityHandler() activityHandler() activityHandler() activityHandler() - await waitFor(() => { - expect(mockPostMessage).toHaveBeenCalledTimes(1) - }) + jest.advanceTimersByTime(20_000) + + // assert + expect(mockPostMessage).toHaveBeenCalledTimes(1) }) it('should ignore errors from activity handler function', async () => { @@ -90,7 +160,7 @@ describe('Activity monitor', () => { }) mockWindowOpener(mockPostMessage) - startActivityMonitor() + renderHook(useActivityMonitor) expect(addEventListenerSpy).toHaveBeenCalledTimes(4) @@ -106,7 +176,7 @@ describe('Activity monitor', () => { mockWindowOpener() - expect(startActivityMonitor).not.toThrow() + expect(() => renderHook(useActivityMonitor)).not.toThrow() expect(addEventListenerSpy).toHaveBeenCalledTimes(1) }) }) diff --git a/redisinsight/ui/src/components/main-router/hooks/useActivityMonitor.ts b/redisinsight/ui/src/components/main-router/hooks/useActivityMonitor.ts new file mode 100644 index 0000000000..ede71eb93d --- /dev/null +++ b/redisinsight/ui/src/components/main-router/hooks/useActivityMonitor.ts @@ -0,0 +1,78 @@ +import throttle from 'lodash/throttle' +import { useEffect } from 'react' +import { getConfig } from 'uiSrc/config' + +const riConfig = getConfig() + +const throttleTimeout = riConfig.app.activityMonitorThrottleTimeout +const windowEvents = ['click', 'keydown', 'scroll', 'touchstart'] + +const SESSION_TIME_SECONDS = riConfig.app.sessionTtlSeconds +const SESSION_TIME_MS = SESSION_TIME_SECONDS * 1000 +const CHECK_SESSION_INTERVAL_MS = 10000 + +let lastActivityTime: number +let checkInterval: ReturnType | null = null + +const getIsMonitorEnabled = () => !!riConfig.app.activityMonitorOrigin + +const onActivity = throttle(() => { + lastActivityTime = +new Date() + + try { + // post event to parent window + window.opener?.postMessage({ name: 'setLastActivity' }, riConfig.app.activityMonitorOrigin) + } catch { + // ignore errors + } +}, throttleTimeout) + +export const startActivityMonitor = () => { + lastActivityTime = +new Date() + try { + if (getIsMonitorEnabled()) { + checkInterval = setInterval(() => { + const now = +new Date() + if (now - lastActivityTime >= SESSION_TIME_MS) { + // expire session + window.location.href = `${riConfig.app.activityMonitorOrigin}/#/logout` + } + }, CHECK_SESSION_INTERVAL_MS) + + windowEvents.forEach((event) => { + window.addEventListener(event, onActivity, { passive: true, capture: true }) + }) + } + } catch { + // ignore errors + } +} + +export const stopActivityMonitor = () => { + try { + if (getIsMonitorEnabled()) { + if (checkInterval) { + clearInterval(checkInterval) + checkInterval = null + } + + windowEvents.forEach((event) => { + window.removeEventListener(event, onActivity, { capture: true }) + }) + } + } catch { + // ignore errors + } +} + +export const useActivityMonitor = () => { + useEffect(() => { + startActivityMonitor() + + return () => { + stopActivityMonitor() + } + }, []) +} + +export default useActivityMonitor diff --git a/redisinsight/ui/src/config/default.ts b/redisinsight/ui/src/config/default.ts index ab2c698299..9277fea321 100644 --- a/redisinsight/ui/src/config/default.ts +++ b/redisinsight/ui/src/config/default.ts @@ -47,6 +47,7 @@ export const defaultConfig = { returnUrlTooltip: process.env.RI_RETURN_URL_TOOLTIP || 'Back', activityMonitorOrigin: process.env.RI_ACTIVITY_MONITOR_ORIGIN, activityMonitorThrottleTimeout: intEnv('RI_ACTIVITY_MONITOR_THROTTLE_TIMEOUT', 30_000), + sessionTtlSeconds: intEnv('RI_SESSION_TTL_SECONDS', 30 * 60), localResourcesBaseUrl: process.env.RI_LOCAL_RESOURCES_BASE_URL, useLocalResources: booleanEnv('RI_USE_LOCAL_RESOURCES', false) }, diff --git a/redisinsight/ui/src/utils/test-utils.tsx b/redisinsight/ui/src/utils/test-utils.tsx index 7201846db2..c8406fe015 100644 --- a/redisinsight/ui/src/utils/test-utils.tsx +++ b/redisinsight/ui/src/utils/test-utils.tsx @@ -319,15 +319,22 @@ export const getMswURL = (path: string = '') => apiService.defaults.baseURL?.concat(path.startsWith('/') ? path.slice(1) : path) ?? '' export const mockWindowLocation = (initialHref = '') => { + const setHrefMock = jest.fn() + let href = initialHref Object.defineProperty(window, 'location', { - configurable: true, value: { - href: initialHref, - assign: jest.fn(), - replace: jest.fn(), - reload: jest.fn(), + set href(url) { + setHrefMock(url) + href = url + }, + get href() { + return href + }, }, + writable: true, }) + + return setHrefMock } // re-export everything diff --git a/yarn.lock b/yarn.lock index 5d60624250..4b3a165b95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2253,6 +2253,14 @@ lodash "^4.17.15" redent "^3.0.0" +"@testing-library/react-hooks@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@testing-library/react@^13.3.0": version "13.4.0" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-13.4.0.tgz#6a31e3bf5951615593ad984e96b9e5e2d9380966" @@ -11237,6 +11245,13 @@ react-dropzone@^11.2.0: file-selector "^0.4.0" prop-types "^15.8.1" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-fast-compare@^2.0.1: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" @@ -12627,16 +12642,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12741,14 +12747,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -14093,7 +14092,7 @@ word-wrap@1.2.4, word-wrap@^1.2.3, word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f" integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -14111,15 +14110,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"