diff --git a/src/network/fetch-utils.ts b/src/network/fetch-utils.ts new file mode 100644 index 00000000..8861e360 --- /dev/null +++ b/src/network/fetch-utils.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2024 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { userAgent } from './user-agent'; +import { JSNS } from '../constants'; +import { useAccountStore } from '../store/account'; +import { useNetworkStore } from '../store/network'; +import type { Account } from '../types/account'; +import type { RawSoapResponse } from '../types/network'; + +const getAccount = ( + acc?: Account, + otherAccount?: string +): { by: string; _content: string } | undefined => { + if (otherAccount) { + return { + by: 'name', + _content: otherAccount + }; + } + if (acc) { + if (acc.name) { + return { + by: 'name', + _content: acc.name + }; + } + if (acc.id) { + return { + by: 'id', + _content: acc.id + }; + } + } + return undefined; +}; + +export const soapFetch = async >( + api: string, + body: Request, + otherAccount?: string, + signal?: AbortSignal +): Promise> => { + const { zimbraVersion, account } = useAccountStore.getState(); + const { notify, session } = useNetworkStore.getState(); + const res = await fetch(`/service/soap/${api}Request`, { + signal, + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + Body: { + [`${api}Request`]: body + }, + Header: { + context: { + _jsns: JSNS.all, + notify: notify?.[0]?.seq + ? { + seq: notify?.[0]?.seq + } + : undefined, + session: session ?? {}, + account: getAccount(account, otherAccount), + userAgent: { + name: userAgent, + version: zimbraVersion + } + } + } + }) + }); + return res.json(); +}; diff --git a/src/network/fetch.ts b/src/network/fetch.ts index ad409595..e0dec629 100644 --- a/src/network/fetch.ts +++ b/src/network/fetch.ts @@ -6,6 +6,7 @@ import { find, map, maxBy } from 'lodash'; +import { soapFetch } from './fetch-utils'; import { userAgent } from './user-agent'; import { goToLogin } from './utils'; import { IS_FOCUS_MODE, JSNS, SHELL_APP_ID } from '../constants'; @@ -34,33 +35,6 @@ export const fetchNoOp = (): void => { ); }; -const getAccount = ( - acc?: Account, - otherAccount?: string -): { by: string; _content: string } | undefined => { - if (otherAccount) { - return { - by: 'name', - _content: otherAccount - }; - } - if (acc) { - if (acc.name) { - return { - by: 'name', - _content: acc.name - }; - } - if (acc.id) { - return { - by: 'id', - _content: acc.id - }; - } - } - return undefined; -}; - const getXmlAccount = (acc?: Account, otherAccount?: string): string => { if (otherAccount) { return `${otherAccount}`; @@ -154,44 +128,14 @@ export const getSoapFetch = body: Request, otherAccount?: string, signal?: AbortSignal - ): Promise => { - const { zimbraVersion, account } = useAccountStore.getState(); - const { notify, session } = useNetworkStore.getState(); - return fetch(`/service/soap/${api}Request`, { - signal, - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - Body: { - [`${api}Request`]: body - }, - Header: { - context: { - _jsns: JSNS.all, - notify: notify?.[0]?.seq - ? { - seq: notify?.[0]?.seq - } - : undefined, - session: session ?? {}, - account: getAccount(account, otherAccount), - userAgent: { - name: userAgent, - version: zimbraVersion - } - } - } - }) - }) // TODO proper error handling - .then((res) => res?.json()) + ): Promise => + soapFetch(api, body, otherAccount, signal) + // TODO proper error handling .then((res: RawSoapResponse) => handleResponse(api, res)) .catch((e) => { report(app)(e); throw e; }) as Promise; - }; export const getXmlSoapFetch = (app: string) => diff --git a/src/network/logout.test.ts b/src/network/logout.test.ts new file mode 100644 index 00000000..1685b042 --- /dev/null +++ b/src/network/logout.test.ts @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2024 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { http, HttpResponse } from 'msw'; + +import { logout } from './logout'; +import * as utils from './utils'; +import server from '../mocks/server'; +import { useLoginConfigStore } from '../store/login/store'; +import { controlConsoleError } from '../tests/utils'; +import type { ErrorSoapResponse } from '../types/network'; + +describe('Logout', () => { + it('should redirect to login page if EndSession request fails', async () => { + const goToLoginFn = jest.spyOn(utils, 'goToLogin').mockImplementation(); + server.use( + http.post('/service/soap/EndSessionRequest', () => HttpResponse.json({}, { status: 500 })) + ); + await logout(); + await jest.advanceTimersToNextTimerAsync(); + expect(goToLoginFn).toHaveBeenCalled(); + }); + + it('should redirect to login page if /logout request fails', async () => { + const goToLoginFn = jest.spyOn(utils, 'goToLogin').mockImplementation(); + server.use(http.get('/logout', () => HttpResponse.json({}, { status: 500 }))); + await logout(); + await jest.advanceTimersToNextTimerAsync(); + expect(goToLoginFn).toHaveBeenCalled(); + }); + + it('should redirect to login page if EndSession throws error', async () => { + controlConsoleError('Failed to fetch'); + const goToLoginFn = jest.spyOn(utils, 'goToLogin').mockImplementation(); + server.use(http.post('/service/soap/EndSessionRequest', () => HttpResponse.error())); + await logout(); + await jest.advanceTimersToNextTimerAsync(); + expect(goToLoginFn).toHaveBeenCalled(); + }); + + it('should redirect to login page if /logout throws error', async () => { + controlConsoleError('Failed to fetch'); + const goToLoginFn = jest.spyOn(utils, 'goToLogin').mockImplementation(); + server.use(http.get('/logout', () => HttpResponse.error())); + await logout(); + await jest.advanceTimersToNextTimerAsync(); + expect(goToLoginFn).toHaveBeenCalled(); + }); + + it('should redirect to login page if EndSession request succeeded with Fault', async () => { + const goToLoginFn = jest.spyOn(utils, 'goToLogin').mockImplementation(); + server.use( + http.post('/service/soap/EndSessionRequest', () => + HttpResponse.json( + { + Header: { context: {} }, + Body: { + Fault: { + Code: { Value: '' }, + Detail: { + Error: { + Code: '', + Trace: '' + } + }, + Reason: { + Text: '' + } + } + } + }, + { status: 200 } + ) + ) + ); + await logout(); + await jest.advanceTimersToNextTimerAsync(); + expect(goToLoginFn).toHaveBeenCalled(); + }); + + describe('with custom logout url', () => { + it('should redirect to login page if EndSession request fails', async () => { + useLoginConfigStore.setState({ carbonioWebUiLogoutURL: 'custom logout' }); + const goToFn = jest.spyOn(utils, 'goTo').mockImplementation(); + server.use( + http.post('/service/soap/EndSessionRequest', () => HttpResponse.json({}, { status: 500 })) + ); + await logout(); + await jest.advanceTimersToNextTimerAsync(); + expect(goToFn).toHaveBeenCalled(); + }); + + it('should redirect to login page if /logout request fails', async () => { + useLoginConfigStore.setState({ carbonioWebUiLogoutURL: 'custom logout' }); + const goToFn = jest.spyOn(utils, 'goTo').mockImplementation(); + server.use(http.get('/logout', () => HttpResponse.json({}, { status: 500 }))); + await logout(); + await jest.advanceTimersToNextTimerAsync(); + expect(goToFn).toHaveBeenCalled(); + }); + + it('should redirect to login page if EndSession throws error', async () => { + useLoginConfigStore.setState({ carbonioWebUiLogoutURL: 'custom logout' }); + controlConsoleError('Failed to fetch'); + const goToFn = jest.spyOn(utils, 'goTo').mockImplementation(); + server.use(http.post('/service/soap/EndSessionRequest', () => HttpResponse.error())); + await logout(); + await jest.advanceTimersToNextTimerAsync(); + expect(goToFn).toHaveBeenCalled(); + }); + + it('should redirect to login page if /logout throws error', async () => { + useLoginConfigStore.setState({ carbonioWebUiLogoutURL: 'custom logout' }); + controlConsoleError('Failed to fetch'); + const goToFn = jest.spyOn(utils, 'goTo').mockImplementation(); + server.use(http.get('/logout', () => HttpResponse.error())); + await logout(); + await jest.advanceTimersToNextTimerAsync(); + expect(goToFn).toHaveBeenCalled(); + }); + + it('should redirect to login page if EndSession request succeeded with Fault', async () => { + useLoginConfigStore.setState({ carbonioWebUiLogoutURL: 'custom logout' }); + const goToFn = jest.spyOn(utils, 'goTo').mockImplementation(); + server.use( + http.post('/service/soap/EndSessionRequest', () => + HttpResponse.json( + { + Header: { context: {} }, + Body: { + Fault: { + Code: { Value: '' }, + Detail: { + Error: { + Code: '', + Trace: '' + } + }, + Reason: { + Text: '' + } + } + } + }, + { status: 200 } + ) + ) + ); + await logout(); + await jest.advanceTimersToNextTimerAsync(); + expect(goToFn).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/network/logout.ts b/src/network/logout.ts index 03475db9..698a985b 100644 --- a/src/network/logout.ts +++ b/src/network/logout.ts @@ -4,22 +4,22 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { getSoapFetch } from './fetch'; +import { soapFetch } from './fetch-utils'; import { goTo, goToLogin } from './utils'; -import { JSNS, SHELL_APP_ID } from '../constants'; +import { JSNS } from '../constants'; import { useLoginConfigStore } from '../store/login/store'; -export function logout(): Promise { - return getSoapFetch(SHELL_APP_ID)('EndSession', { - _jsns: JSNS.account, - logoff: true - }) - .then(() => fetch('/logout', { redirect: 'manual' })) - .then(() => { - const customLogoutUrl = useLoginConfigStore.getState().carbonioWebUiLogoutURL; - customLogoutUrl ? goTo(customLogoutUrl) : goToLogin(); - }) - .catch((error) => { - console.error(error); +export async function logout(): Promise { + try { + await soapFetch('EndSession', { + _jsns: JSNS.account, + logoff: true }); + await fetch('/logout', { redirect: 'manual' }); + } catch (error) { + console.error(error); + } finally { + const customLogoutUrl = useLoginConfigStore.getState().carbonioWebUiLogoutURL; + customLogoutUrl ? goTo(customLogoutUrl) : goToLogin(); + } }