From f9d30b6b646f052bf312d68dcf70f9e54156d46d Mon Sep 17 00:00:00 2001 From: Beatrice Guerra Date: Tue, 11 Jul 2023 14:29:47 +0200 Subject: [PATCH 1/2] feat(whitelabel): redirect to custom logout url when user logout test: add test for logout and improve other tests refactor: improve load functions test: refactor test and mock reporting and workers for all tests refactor: use direct import for reporting and remove index file refs: SHELL-121 (#280) SHELL-101 (#281) --- jest.config.ts | 3 +- package.json | 4 +- src/boot/app/load-app.ts | 29 ++--- src/boot/app/load-apps.ts | 11 +- src/boot/loader.test.tsx | 114 +++++++++--------- src/boot/loader.tsx | 2 +- src/jest-env-setup.ts | 21 ++-- src/jest-polyfills.ts | 11 ++ src/mocks/handlers.ts | 9 +- src/mocks/handlers/endSessionRequest.ts | 31 +++++ src/mocks/handlers/logout.ts | 13 ++ src/mocks/handlers/rootHandler.ts | 16 +++ src/mocks/server.ts | 32 ++++- src/network/fetch.test.ts | 74 ++++++++++++ src/network/fetch.ts | 4 +- src/network/get-components.test.ts | 6 +- src/network/go-to-login.ts | 9 -- src/network/logout.ts | 24 ++-- src/network/utils.ts | 13 ++ src/reporting/__mocks__/store.ts | 22 ++++ src/reporting/index.ts | 7 -- src/settings/accounts-settings.test.tsx | 72 +++-------- .../general-settings/logout.test.tsx | 50 ++++++++ .../components/general-settings/logout.tsx | 17 ++- src/settings/general-settings.tsx | 2 +- src/shell/hooks/useLocalStorage.test.tsx | 16 +-- src/shell/shell-primary-bar.test.tsx | 14 +-- src/shell/shell-view.tsx | 2 +- src/test/account-utils.ts | 27 +++++ src/test/constants.ts | 3 +- src/test/utils.tsx | 19 +++ src/utility-bar/bar.test.tsx | 83 +++++++++++++ tsconfig.json | 5 +- types/loginConfig/index.d.ts | 1 + 34 files changed, 546 insertions(+), 220 deletions(-) create mode 100644 src/mocks/handlers/endSessionRequest.ts create mode 100644 src/mocks/handlers/logout.ts create mode 100644 src/mocks/handlers/rootHandler.ts create mode 100644 src/network/fetch.test.ts delete mode 100644 src/network/go-to-login.ts create mode 100644 src/network/utils.ts create mode 100644 src/reporting/__mocks__/store.ts delete mode 100644 src/reporting/index.ts create mode 100644 src/settings/components/general-settings/logout.test.tsx create mode 100644 src/test/account-utils.ts create mode 100644 src/utility-bar/bar.test.tsx diff --git a/jest.config.ts b/jest.config.ts index 7e29a444..083938e3 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -29,6 +29,7 @@ export default { '!src/**/types/*', // exclude types '!src/**/*.d.ts', // exclude declarations '!src/test/*', // exclude test folder + '!**/__mocks__/**/*', // exclude manual mocks '!src/workers/*' // FIXME: exclude worker folder which throws error because of the esm syntax ], @@ -106,7 +107,7 @@ export default { }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader - // modulePathIgnorePatterns: [], + modulePathIgnorePatterns: ['/.*/__mocks__'], // Activates notifications for test results // notify: false, diff --git a/package.json b/package.json index daf17842..0778e9e1 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ }, "scripts": { "build:clean": "rm -rf lib && rm -rf dist && rm -rf pkg", - "test": "jest", + "test:ci": "jest --testTimeout=10000 --maxWorkers=50%", + "test:dev": "jest", + "test": "is-ci && npm run test:ci || npm run test:dev", "prepare": "is-ci || husky install", "prepack": "npm run build:clean && npm run build -- -d", "postpublish": "rm -rf lib", diff --git a/src/boot/app/load-app.ts b/src/boot/app/load-app.ts index 5c1dfb89..5a997c3c 100644 --- a/src/boot/app/load-app.ts +++ b/src/boot/app/load-app.ts @@ -13,27 +13,14 @@ import { AppLink } from '../../ui-extras/app-link'; import * as CONSTANTS from '../../constants'; import type { CarbonioModule } from '../../../types'; import { getAppSetters } from './app-loader-setters'; -import { report } from '../../reporting'; +import { report } from '../../reporting/functions'; import SettingsHeader from '../../settings/components/settings-header'; export const _scripts: { [pkgName: string]: HTMLScriptElement } = {}; let _scriptId = 0; export function loadApp(appPkg: CarbonioModule): Promise { - return new Promise((_resolve, _reject) => { - let resolved = false; - const resolve = (): void => { - if (!resolved) { - resolved = true; - _resolve(appPkg); - } - }; - const reject = (e: unknown): void => { - if (!resolved) { - resolved = true; - _reject(e); - } - }; + return new Promise((resolve, reject) => { try { if ( window.__ZAPP_SHARED_LIBRARIES__ && @@ -61,7 +48,7 @@ export function loadApp(appPkg: CarbonioModule): Promise { `%c loaded ${appPkg.name}`, 'color: white; background: #539507;padding: 4px 8px 2px 4px; font-family: sans-serif; border-radius: 12px; width: 100%' ); - resolve(); + resolve(appPkg); }; const script = document.createElement('script'); @@ -71,7 +58,8 @@ export function loadApp(appPkg: CarbonioModule): Promise { script.setAttribute('data-is_app', 'true'); script.setAttribute('src', `${appPkg.js_entrypoint}`); document.body.appendChild(script); - _scripts[`${appPkg.name}-loader-${(_scriptId += 1)}`] = script; + _scriptId += 1; + _scripts[`${appPkg.name}-loader-${_scriptId}`] = script; } catch (err: unknown) { console.error(err); reject(err); @@ -80,9 +68,12 @@ export function loadApp(appPkg: CarbonioModule): Promise { } export function unloadApps(): Promise { - return Promise.resolve().then(() => { + return new Promise((resolve) => { forOwn(_scripts, (script) => { - if (script.parentNode) script.parentNode.removeChild(script); + if (script.parentNode) { + script.parentNode.removeChild(script); + } }); + resolve(); }); } diff --git a/src/boot/app/load-apps.ts b/src/boot/app/load-apps.ts index 413d36cd..a12122ea 100644 --- a/src/boot/app/load-apps.ts +++ b/src/boot/app/load-apps.ts @@ -10,7 +10,7 @@ import { registerLocale, setDefaultLocale } from '@zextras/carbonio-design-syste import type { Locale as DateFnsLocale } from 'date-fns'; import { CarbonioModule } from '../../../types'; import { SHELL_APP_ID } from '../../constants'; -import { useReporter } from '../../reporting'; +import { useReporter } from '../../reporting/store'; import { getUserSetting, useAccountStore } from '../../store/account'; import { getT, useI18nStore } from '../../store/i18n'; import { loadApp, unloadApps } from './load-app'; @@ -20,12 +20,13 @@ import { localeList } from '../../settings/components/utils'; const getDateFnsLocale = (locale: string): Promise => import(`date-fns/locale/${locale}/index.js`); -export function loadApps(apps: Array): void { +export function loadApps( + apps: Array +): Promise[]> { injectSharedLibraries(); const appsToLoad = filter(apps, (app) => { if (app.name === SHELL_APP_ID) return false; - if (app.attrKey && getUserSetting('attrs', app.attrKey) === 'FALSE') return false; - return true; + return !(app.attrKey && getUserSetting('attrs', app.attrKey) === 'FALSE'); }); console.log( '%cLOADING APPS', @@ -46,7 +47,7 @@ export function loadApps(apps: Array): void { }); } useReporter.getState().setClients(appsToLoad); - Promise.allSettled(map(appsToLoad, (app) => loadApp(app))); + return Promise.allSettled(map(appsToLoad, (app) => loadApp(app))); } export function unloadAllApps(): Promise { diff --git a/src/boot/loader.test.tsx b/src/boot/loader.test.tsx index a71e5b14..7bf09206 100644 --- a/src/boot/loader.test.tsx +++ b/src/boot/loader.test.tsx @@ -3,20 +3,16 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { ResponseResolver, rest, RestContext, RestRequest } from 'msw'; -import { act, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import server from '../mocks/server'; -import { getComponentsJson, GetComponentsJsonResponseBody } from '../mocks/handlers/components'; -import { setup } from '../test/utils'; + +import { act, screen } from '@testing-library/react'; +import { rest } from 'msw'; + import { Loader } from './loader'; import { LOGIN_V3_CONFIG_PATH } from '../constants'; -import { LoginConfigStore } from '../../types/loginConfig'; -import { getLoginConfig } from '../mocks/handlers/login-config'; -import { getInfoRequest } from '../mocks/handlers/getInfoRequest'; - -jest.mock('../workers'); -jest.mock('../reporting/functions'); +import { GetComponentsJsonResponseBody } from '../mocks/handlers/components'; +import server, { waitForResponse } from '../mocks/server'; +import { controlConsoleError, setup } from '../test/utils'; describe('Loader', () => { test('If only getComponents request fails, the LoaderFailureModal appears', async () => { @@ -29,8 +25,19 @@ describe('Loader', () => { ) ); - setup(); + const loginRes = waitForResponse('get', LOGIN_V3_CONFIG_PATH); + const componentsRes = waitForResponse('get', '/static/iris/components.json'); + const getInfoRes = waitForResponse('post', '/service/soap/GetInfoRequest'); + setup( + + + + ); + await loginRes; + await screen.findByTestId('loader'); + await componentsRes; + await getInfoRes; const title = await screen.findByText('Something went wrong...'); act(() => { jest.runOnlyPendingTimers(); @@ -40,24 +47,25 @@ describe('Loader', () => { test('If only getInfo request fails, the LoaderFailureModal appears', async () => { // TODO remove when SHELL-117 will be implemented - const actualConsoleError = console.error; - console.error = jest.fn, Parameters>( - (error, ...restParameter) => { - if (error === 'Unexpected end of JSON input') { - console.log('Controlled error', error, ...restParameter); - } else { - actualConsoleError(error, ...restParameter); - } - } - ); + controlConsoleError('Unexpected end of JSON input'); // using getComponents and loginConfig default handlers server.use( rest.post('/service/soap/GetInfoRequest', (req, res, ctx) => res(ctx.status(503, 'Controlled error: fail getInfo request')) ) ); - - setup(); + const loginRes = waitForResponse('get', LOGIN_V3_CONFIG_PATH); + const componentsRes = waitForResponse('get', '/static/iris/components.json'); + const getInfoRes = waitForResponse('post', '/service/soap/GetInfoRequest'); + setup( + + + + ); + await loginRes; + await screen.findByTestId('loader'); + await componentsRes; + await getInfoRes; const title = await screen.findByText('Something went wrong...'); act(() => { @@ -67,47 +75,35 @@ describe('Loader', () => { }); test('If only loginConfig request fails, the LoaderFailureModal does not appear', async () => { - const getComponentsJsonHandler = jest.fn(getComponentsJson); - const getInfoHandler = jest.fn(getInfoRequest); - type LoginConfigHandler = ResponseResolver< - RestRequest, - RestContext, - Partial> - >; - const loginConfigHandler = jest.fn< - ReturnType, - Parameters - >((req, res, ctx) => res(ctx.status(503))); - server.use( - rest.get('/static/iris/components.json', getComponentsJsonHandler), - rest.post('/service/soap/GetInfoRequest', getInfoHandler), - rest.get(LOGIN_V3_CONFIG_PATH, loginConfigHandler) + server.use(rest.get(LOGIN_V3_CONFIG_PATH, (req, res, ctx) => res(ctx.status(503)))); + const loginRes = waitForResponse('get', LOGIN_V3_CONFIG_PATH); + const componentsRes = waitForResponse('get', '/static/iris/components.json'); + const getInfoRes = waitForResponse('post', '/service/soap/GetInfoRequest'); + setup( + + + ); - - setup(); - - await waitFor(() => expect(loginConfigHandler).toHaveBeenCalled()); - await waitFor(() => expect(getComponentsJsonHandler).toHaveBeenCalled()); - await waitFor(() => expect(getInfoHandler).toHaveBeenCalled()); - + await loginRes; + await screen.findByTestId('loader'); + await componentsRes; + await getInfoRes; expect(screen.queryByText('Something went wrong...')).not.toBeInTheDocument(); }); test('If Loader requests do not fail, the LoaderFailureModal does not appear', async () => { - const loginConfigHandler = jest.fn(getLoginConfig); - const getComponentsJsonHandler = jest.fn(getComponentsJson); - const getInfoHandler = jest.fn(getInfoRequest); - - server.use( - rest.get('/static/iris/components.json', getComponentsJsonHandler), - rest.post('/service/soap/GetInfoRequest', getInfoHandler), - rest.get(LOGIN_V3_CONFIG_PATH, loginConfigHandler) + const loginRes = waitForResponse('get', LOGIN_V3_CONFIG_PATH); + const componentsRes = waitForResponse('get', '/static/iris/components.json'); + const getInfoRes = waitForResponse('post', '/service/soap/GetInfoRequest'); + setup( + + + ); - setup(); - - await waitFor(() => expect(loginConfigHandler).toHaveBeenCalled()); - await waitFor(() => expect(getComponentsJsonHandler).toHaveBeenCalled()); - await waitFor(() => expect(getInfoHandler).toHaveBeenCalled()); + await loginRes; + await screen.findByTestId('loader'); + await componentsRes; + await getInfoRes; expect(screen.queryByText('Something went wrong...')).not.toBeInTheDocument(); }); diff --git a/src/boot/loader.tsx b/src/boot/loader.tsx index bdee656d..3c310bdf 100644 --- a/src/boot/loader.tsx +++ b/src/boot/loader.tsx @@ -12,7 +12,7 @@ import { useAppStore } from '../store/app'; import { getInfo } from '../network/get-info'; import { loadApps, unloadAllApps } from './app/load-apps'; import { loginConfig } from '../network/login-config'; -import { goToLogin } from '../network/go-to-login'; +import { goToLogin } from '../network/utils'; import { getComponents } from '../network/get-components'; export function isPromiseRejectedResult( diff --git a/src/jest-env-setup.ts b/src/jest-env-setup.ts index 29c04a39..b5de5678 100644 --- a/src/jest-env-setup.ts +++ b/src/jest-env-setup.ts @@ -9,6 +9,7 @@ import { act, configure } from '@testing-library/react'; import dotenv from 'dotenv'; import failOnConsole from 'jest-fail-on-console'; import { noop } from 'lodash'; + import server from './mocks/server'; dotenv.config(); @@ -47,17 +48,6 @@ beforeEach(() => { }) }); - Object.defineProperty(window, 'ResizeObserver', { - writable: true, - value: function ResizeObserverMock(): ResizeObserver { - return { - observe: jest.fn(), - unobserve: jest.fn(), - disconnect: jest.fn() - }; - } - }); - // cleanup local storage window.localStorage.clear(); @@ -77,9 +67,16 @@ afterAll(() => { }); afterEach(() => { - jest.runOnlyPendingTimers(); + act(() => { + jest.runOnlyPendingTimers(); + }); + server.events.removeAllListeners(); server.resetHandlers(); act(() => { window.resizeTo(1024, 768); }); }); + +jest.mock('./workers'); +jest.mock('./reporting/functions'); +jest.mock('./reporting/store'); diff --git a/src/jest-polyfills.ts b/src/jest-polyfills.ts index 489ede22..ecc181f1 100644 --- a/src/jest-polyfills.ts +++ b/src/jest-polyfills.ts @@ -37,3 +37,14 @@ window.resizeTo = function resizeTo(width, height): void { outerHeight: height }).dispatchEvent(new this.Event('resize')); }; + +Object.defineProperty(window, 'ResizeObserver', { + writable: true, + value: function ResizeObserverMock(): ResizeObserver { + return { + observe: noop, + unobserve: noop, + disconnect: noop + }; + } +}); diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index f8612967..0f7c3224 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -5,17 +5,22 @@ */ import { type RequestHandler, rest } from 'msw'; + import { getComponentsJson } from './handlers/components'; +import { endSessionRequest } from './handlers/endSessionRequest'; import { getInfoRequest } from './handlers/getInfoRequest'; -import { LOGIN_V3_CONFIG_PATH } from '../constants'; -import { getLoginConfig } from './handlers/login-config'; import { getRightsRequest } from './handlers/getRightsRequest'; +import { getLoginConfig } from './handlers/login-config'; +import { rootHandler } from './handlers/rootHandler'; +import { LOGIN_V3_CONFIG_PATH } from '../constants'; const handlers: RequestHandler[] = [ rest.get('/static/iris/components.json', getComponentsJson), rest.post('/service/soap/GetInfoRequest', getInfoRequest), rest.post('/service/soap/GetRightsRequest', getRightsRequest), + rest.post('/service/soap/EndSessionRequest', endSessionRequest), rest.get(LOGIN_V3_CONFIG_PATH, getLoginConfig), + rest.get('/', rootHandler), rest.get('/i18n/en.json', (request, response, context) => response(context.json({}))) ]; diff --git a/src/mocks/handlers/endSessionRequest.ts b/src/mocks/handlers/endSessionRequest.ts new file mode 100644 index 00000000..3adc2fda --- /dev/null +++ b/src/mocks/handlers/endSessionRequest.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2023 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { type ResponseResolver, type RestContext, type RestRequest } from 'msw'; + +type EndSessionRequestBody = { + EndSessionRequest: { + _jsns: string; + }; +}; + +type EndSessionResponseBody = { + Body: { + EndSessionResponse: Record; + }; +}; + +export const endSessionRequest: ResponseResolver< + RestRequest, + RestContext, + EndSessionResponseBody +> = (request, response, context) => + response( + context.json({ + Body: { + EndSessionResponse: {} + } + }) + ); diff --git a/src/mocks/handlers/logout.ts b/src/mocks/handlers/logout.ts new file mode 100644 index 00000000..7d02f8bb --- /dev/null +++ b/src/mocks/handlers/logout.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2023 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { ResponseResolver, RestContext, RestRequest } from 'msw'; + +export const logout: ResponseResolver = (req, res, ctx) => + res( + ctx.status(304, 'Temporary Redirect'), + ctx.json({}), + ctx.set('location', 'https://localhost/static/login/') + ); diff --git a/src/mocks/handlers/rootHandler.ts b/src/mocks/handlers/rootHandler.ts new file mode 100644 index 00000000..4ff57347 --- /dev/null +++ b/src/mocks/handlers/rootHandler.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2023 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { ResponseResolver, RestContext, RestRequest } from 'msw'; + +import { logout } from './logout'; + +export const rootHandler: ResponseResolver = (req, res, ctx) => { + if (req.url.searchParams.get('loginOp') === 'logout') { + return logout(req, res, ctx); + } + + return req.passthrough(); +}; diff --git a/src/mocks/server.ts b/src/mocks/server.ts index 13ef493e..fb4fe07a 100644 --- a/src/mocks/server.ts +++ b/src/mocks/server.ts @@ -5,7 +5,7 @@ */ import { matchRequestUrl, MockedRequest } from 'msw'; -import { setupServer } from 'msw/node'; +import { ServerLifecycleEventsMap, setupServer } from 'msw/node'; import handlers from './handlers'; @@ -38,3 +38,33 @@ export function waitForRequest(method: string, url: string): Promise { + let requestId = ''; + + return new Promise((resolve, reject) => { + server.events.on('request:start', (req) => { + const matchesMethod = req.method.toLowerCase() === method.toLowerCase(); + const matchesUrl = matchRequestUrl(req.url, url).matches; + + if (matchesMethod && matchesUrl) { + requestId = req.id; + } + }); + + server.events.on('response:mocked', (res, reqId) => { + if (reqId === requestId) { + resolve(res); + } + }); + + server.events.on('request:unhandled', (req) => { + if (req.id === requestId) { + reject(new Error(`The ${req.method} ${req.url.href} request was unhandled.`)); + } + }); + }); +} diff --git a/src/network/fetch.test.ts b/src/network/fetch.test.ts new file mode 100644 index 00000000..3f45d5dc --- /dev/null +++ b/src/network/fetch.test.ts @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2023 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { waitFor } from '@testing-library/react'; +import { noop } from 'lodash'; +import { DefaultBodyType, PathParams, rest } from 'msw'; + +import { getSoapFetch } from './fetch'; +import * as networkUtils from './utils'; +import { type ErrorSoapResponse } from '../../types'; +import { SHELL_APP_ID } from '../constants'; +import server from '../mocks/server'; + +describe('Fetch', () => { + test('should redirect to login if user is not authenticated', async () => { + server.use( + rest.post>( + '/service/soap/SomeRequest', + (req, res, ctx) => + res( + ctx.json({ + Body: { + Fault: { + Reason: { Text: 'Controlled error: auth required' }, + Detail: { + Error: { + Code: 'service.AUTH_REQUIRED', + Detail: '' + } + } + } + } + }) + ) + ) + ); + + const goToLoginFn = jest.spyOn(networkUtils, 'goToLogin').mockImplementation(noop); + + await getSoapFetch(SHELL_APP_ID)('Some', {}); + await waitFor(() => expect(goToLoginFn).toHaveBeenCalled()); + }); + + test('should redirect to login if user session is expired', async () => { + server.use( + rest.post>( + '/service/soap/SomeRequest', + (req, res, ctx) => + res( + ctx.json({ + Body: { + Fault: { + Reason: { Text: 'Controlled error: auth expired' }, + Detail: { + Error: { + Code: 'service.AUTH_EXPIRED', + Detail: '' + } + } + } + } + }) + ) + ) + ); + + const goToLoginFn = jest.spyOn(networkUtils, 'goToLogin').mockImplementation(noop); + + await getSoapFetch(SHELL_APP_ID)('Some', {}); + await waitFor(() => expect(goToLoginFn).toHaveBeenCalled()); + }); +}); diff --git a/src/network/fetch.ts b/src/network/fetch.ts index 8e1979d0..dcb01dac 100644 --- a/src/network/fetch.ts +++ b/src/network/fetch.ts @@ -5,7 +5,7 @@ */ import { find, map, maxBy } from 'lodash'; -import { goToLogin } from './go-to-login'; +import { goToLogin } from './utils'; import { Account, ErrorSoapBodyResponse, @@ -14,7 +14,7 @@ import { SoapResponse } from '../../types'; import { userAgent } from './user-agent'; -import { report } from '../reporting'; +import { report } from '../reporting/functions'; import { useAccountStore } from '../store/account'; import { IS_STANDALONE, SHELL_APP_ID } from '../constants'; import { useNetworkStore } from '../store/network'; diff --git a/src/network/get-components.test.ts b/src/network/get-components.test.ts index 10de4736..32fdf8f8 100644 --- a/src/network/get-components.test.ts +++ b/src/network/get-components.test.ts @@ -4,13 +4,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { rest } from 'msw'; -import server from '../mocks/server'; + +import { getComponents } from './get-components'; import { CarbonioModule } from '../../types'; import { GetComponentsJsonResponseBody } from '../mocks/handlers/components'; +import server from '../mocks/server'; import { useAppStore } from '../store/app'; -import { getComponents } from './get-components'; -jest.mock('../workers'); describe('Get components', () => { test('Setup apps and request data for the logged account', async () => { const shellModule: CarbonioModule = { diff --git a/src/network/go-to-login.ts b/src/network/go-to-login.ts deleted file mode 100644 index 064519b2..00000000 --- a/src/network/go-to-login.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const goToLogin: () => void = () => { - window?.location?.assign?.(`${window?.location?.origin}/static/login`); -}; diff --git a/src/network/logout.ts b/src/network/logout.ts index c04d8cc8..08216d16 100644 --- a/src/network/logout.ts +++ b/src/network/logout.ts @@ -4,16 +4,22 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { SHELL_APP_ID } from '../constants'; import { getSoapFetch } from './fetch'; -import { goToLogin } from './go-to-login'; +import { goTo, goToLogin } from './utils'; +import { SHELL_APP_ID } from '../constants'; +import { useLoginConfigStore } from '../store/login/store'; -export const logout = (): Promise => - getSoapFetch(SHELL_APP_ID)('EndSession', { +export function logout(): Promise { + return getSoapFetch(SHELL_APP_ID)('EndSession', { _jsns: 'urn:zimbraAccount', logoff: true - }).then(() => { - fetch('/?loginOp=logout') - .then((res) => res) - .then(goToLogin); - }); + }) + .then(() => fetch('/?loginOp=logout', { redirect: 'manual' })) + .then(() => { + const customLogoutUrl = useLoginConfigStore.getState().carbonioWebUiLogoutURL; + customLogoutUrl ? goTo(customLogoutUrl) : goToLogin(); + }) + .catch((error) => { + console.error(error); + }); +} diff --git a/src/network/utils.ts b/src/network/utils.ts new file mode 100644 index 00000000..9d4ae58a --- /dev/null +++ b/src/network/utils.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2021 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function goToLogin(): void { + window.location.assign(`${window.location.origin}/static/login`); +} + +export function goTo(location: string): void { + window.location.assign(location); +} diff --git a/src/reporting/__mocks__/store.ts b/src/reporting/__mocks__/store.ts new file mode 100644 index 00000000..8db54ea4 --- /dev/null +++ b/src/reporting/__mocks__/store.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2022 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { type Hub } from '@sentry/browser'; +import { create } from 'zustand'; + +import { CarbonioModule } from '../../../types'; + +type ReporterState = { + clients: Record; + setClients: (apps: Array) => void; +}; + +export const useReporter = create()(() => ({ + clients: {}, + setClients: (): void => { + // do nothing + } +})); diff --git a/src/reporting/index.ts b/src/reporting/index.ts deleted file mode 100644 index 58ce6dd3..00000000 --- a/src/reporting/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 Zextras - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -export * from './store'; -export * from './functions'; diff --git a/src/settings/accounts-settings.test.tsx b/src/settings/accounts-settings.test.tsx index 3b909b12..c148a908 100644 --- a/src/settings/accounts-settings.test.tsx +++ b/src/settings/accounts-settings.test.tsx @@ -17,9 +17,12 @@ import { Account, BatchRequest, IdentityProps } from '../../types'; import server, { waitForRequest } from '../mocks/server'; import { setup } from '../test/utils'; -jest.mock('../workers'); - describe('Account setting', () => { + async function waitForGetRightsRequest(): Promise { + await waitForRequest('post', '/service/soap/GetRightsRequest'); + await screen.findByText('sendAs'); + } + test('Show primary identity inside the list', async () => { const fullName = faker.person.fullName(); const email = faker.internet.email(); @@ -54,10 +57,8 @@ describe('Account setting', () => { )} /> ); - act(() => { - jest.runOnlyPendingTimers(); - }); - await screen.findByText('sendAs'); + + await waitForGetRightsRequest(); expect(screen.getByText(fullName)).toBeVisible(); expect(screen.getByText(`(${email})`)).toBeVisible(); expect(screen.getByText('Primary')).toBeVisible(); @@ -101,10 +102,7 @@ describe('Account setting', () => { )} /> ); - act(() => { - jest.runOnlyPendingTimers(); - }); - await screen.findByText('sendAs'); + await waitForGetRightsRequest(); await user.click(screen.getByRole('button', { name: /add persona/i })); expect(screen.getByText('New Persona 1')).toBeVisible(); }); @@ -171,10 +169,7 @@ describe('Account setting', () => { const { user } = setup( ); - act(() => { - jest.runOnlyPendingTimers(); - }); - await screen.findByText('sendAs'); + await waitForGetRightsRequest(); expect(screen.getByText('New Persona 1')).toBeVisible(); await user.click(screen.getByRole('button', { name: /add persona/i })); @@ -243,10 +238,7 @@ describe('Account setting', () => { const { user } = setup( ); - act(() => { - jest.runOnlyPendingTimers(); - }); - await screen.findByText('sendAs'); + await waitForGetRightsRequest(); expect(screen.getByText(persona1FullName)).toBeVisible(); await user.click(screen.getByRole('button', { name: /add persona/i })); @@ -315,10 +307,7 @@ describe('Account setting', () => { const { user } = setup( ); - act(() => { - jest.runOnlyPendingTimers(); - }); - await screen.findByText('sendAs'); + await waitForGetRightsRequest(); const persona1Row = screen.getByText(persona1FullName); expect(persona1Row).toBeVisible(); await user.click(persona1Row); @@ -397,10 +386,7 @@ describe('Account setting', () => { ); - act(() => { - jest.runOnlyPendingTimers(); - }); - await screen.findByText('sendAs'); + await waitForGetRightsRequest(); await user.click(screen.getByRole('button', { name: /add persona/i })); await waitFor(() => @@ -458,10 +444,6 @@ describe('Account setting', () => { const request = await pendingBatchRequest; - act(() => { - jest.runOnlyPendingTimers(); - }); - const requestBody = (request?.body as { Body: { BatchRequest: BatchRequest } }).Body; expect(requestBody.BatchRequest.CreateIdentityRequest).toHaveLength(1); expect(requestBody.BatchRequest.DeleteIdentityRequest).toBeUndefined(); @@ -469,10 +451,6 @@ describe('Account setting', () => { const successSnackbar = await screen.findByText('Edits saved correctly'); expect(successSnackbar).toBeVisible(); - act(() => { - // close snackbar before exiting test - jest.runOnlyPendingTimers(); - }); }); test('Should remove from the list added identities not saved on discard changes', async () => { @@ -523,10 +501,7 @@ describe('Account setting', () => { ); - act(() => { - jest.runOnlyPendingTimers(); - }); - await screen.findByText('sendAs'); + await waitForGetRightsRequest(); const persona1 = 'New Persona 1'; await user.click(screen.getByRole('button', { name: /add persona/i })); @@ -598,10 +573,7 @@ describe('Account setting', () => { const { user } = setup( ); - act(() => { - jest.runOnlyPendingTimers(); - }); - await screen.findByText('sendAs'); + await waitForGetRightsRequest(); expect(screen.getByText(persona1FullName)).toBeVisible(); @@ -609,6 +581,7 @@ describe('Account setting', () => { await user.click(screen.getByRole('button', { name: /delete/i })); const confirmButton = screen.getByRole('button', { name: /delete permanently/i }); act(() => { + // run modal timers jest.runOnlyPendingTimers(); }); await user.click(confirmButton); @@ -666,10 +639,7 @@ describe('Account setting', () => { const { user } = setup( ); - act(() => { - jest.runOnlyPendingTimers(); - }); - await screen.findByText('sendAs'); + await waitForGetRightsRequest(); const accountNameInput = screen.getByRole('textbox', { name: /account name/i }); expect(accountNameInput).toHaveDisplayValue(defaultFullName); @@ -735,10 +705,7 @@ describe('Account setting', () => { const { user } = setup( ); - act(() => { - jest.runOnlyPendingTimers(); - }); - await screen.findByText('sendAs'); + await waitForGetRightsRequest(); const accountNameInput = screen.getByRole('textbox', { name: /account name/i }); expect(accountNameInput).toHaveDisplayValue(defaultFullName); @@ -811,10 +778,7 @@ describe('Account setting', () => { const { user } = setup( ); - act(() => { - jest.runOnlyPendingTimers(); - }); - await screen.findByText('sendAs'); + await waitForGetRightsRequest(); const emailAddressInput = screen.getByRole('textbox', { name: /E-mail address/i }); expect(emailAddressInput).toHaveDisplayValue(defaultEmail); diff --git a/src/settings/components/general-settings/logout.test.tsx b/src/settings/components/general-settings/logout.test.tsx new file mode 100644 index 00000000..92fe540a --- /dev/null +++ b/src/settings/components/general-settings/logout.test.tsx @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2023 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React from 'react'; + +import { act, screen, waitFor } from '@testing-library/react'; + +import { Logout } from './logout'; +import { waitForRequest } from '../../../mocks/server'; +import * as networkUtils from '../../../network/utils'; +import { useLoginConfigStore } from '../../../store/login/store'; +import { setup } from '../../../test/utils'; + +describe('Logout', () => { + test('should redirect to custom logout url on manual logout', async () => { + const customLogout = 'custom.logout.url'; + const goToFn = jest.spyOn(networkUtils, 'goTo').mockImplementation(); + const goToLoginFn = jest.spyOn(networkUtils, 'goToLogin').mockImplementation(); + useLoginConfigStore.setState((s) => ({ ...s, carbonioWebUiLogoutURL: customLogout })); + const { user } = setup(); + const logout = waitForRequest('get', '/?loginOp=logout'); + await user.click(screen.getByRole('button', { name: /logout/i })); + await logout; + act(() => { + jest.runOnlyPendingTimers(); + }); + await waitFor(() => expect(goToFn).toHaveBeenCalled()); + expect(goToFn).toHaveBeenCalledTimes(1); + expect(goToFn).toHaveBeenCalledWith(customLogout); + expect(goToLoginFn).not.toHaveBeenCalled(); + }); + + test('should redirect to login if no custom logout url is set', async () => { + const goToFn = jest.spyOn(networkUtils, 'goTo').mockImplementation(); + const goToLoginFn = jest.spyOn(networkUtils, 'goToLogin').mockImplementation(); + useLoginConfigStore.setState((s) => ({ ...s, carbonioWebUiLogoutURL: '' })); + const { user } = setup(); + const logout = waitForRequest('get', '/?loginOp=logout'); + await user.click(screen.getByRole('button', { name: /logout/i })); + await logout; + act(() => { + jest.runOnlyPendingTimers(); + }); + await waitFor(() => expect(goToLoginFn).toHaveBeenCalled()); + expect(goToLoginFn).toHaveBeenCalledTimes(1); + expect(goToFn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/settings/components/general-settings/logout.tsx b/src/settings/components/general-settings/logout.tsx index 6c6a1dd4..1f119ce2 100644 --- a/src/settings/components/general-settings/logout.tsx +++ b/src/settings/components/general-settings/logout.tsx @@ -5,17 +5,16 @@ */ import { Button, FormSubSection } from '@zextras/carbonio-design-system'; -import React, { FC, useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { logout } from '../../../network/logout'; -import { getT } from '../../../store/i18n'; import { accountSubSection } from '../../general-settings-sub-sections'; -const Logout: FC = () => { - const t = getT(); - const onClick = useCallback(() => { - logout(); - }, []); +export const Logout = (): JSX.Element => { + const [t] = useTranslation(); + const sectionTitle = useMemo(() => accountSubSection(t), [t]); + return ( { width="50%" id={sectionTitle.id} > -