diff options
Diffstat (limited to 'core/src/tests')
-rw-r--r-- | core/src/tests/.eslintrc.js | 22 | ||||
-rw-r--r-- | core/src/tests/OC/requesttoken.spec.js | 73 | ||||
-rw-r--r-- | core/src/tests/OC/requesttoken.spec.ts | 147 | ||||
-rw-r--r-- | core/src/tests/OC/session-heartbeat.spec.ts | 123 | ||||
-rw-r--r-- | core/src/tests/components/ContactsMenu/Contact.spec.js | 44 | ||||
-rw-r--r-- | core/src/tests/views/ContactsMenu.spec.js | 143 |
6 files changed, 459 insertions, 93 deletions
diff --git a/core/src/tests/.eslintrc.js b/core/src/tests/.eslintrc.js index b44ea2c697d..598fc5c28b4 100644 --- a/core/src/tests/.eslintrc.js +++ b/core/src/tests/.eslintrc.js @@ -1,25 +1,7 @@ /** - * @copyright Copyright (c) 2016 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - module.exports = { globals: { jsdom: true, diff --git a/core/src/tests/OC/requesttoken.spec.js b/core/src/tests/OC/requesttoken.spec.js deleted file mode 100644 index 741dc65746b..00000000000 --- a/core/src/tests/OC/requesttoken.spec.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author François Freitag <mail@franek.fr> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import { subscribe, unsubscribe } from '@nextcloud/event-bus' - -import { manageToken, setToken } from '../../OC/requesttoken' - -describe('request token', () => { - - let emit - let manager - const token = 'abc123' - - beforeEach(() => { - emit = jest.fn() - const head = window.document.getElementsByTagName('head')[0] - head.setAttribute('data-requesttoken', token) - - manager = manageToken(window.document, emit) - }) - - test('reads the token from the document', () => { - expect(manager.getToken()).toBe('abc123') - }) - - test('remembers the updated token', () => { - manager.setToken('bca321') - - expect(manager.getToken()).toBe('bca321') - }) - - describe('@nextcloud/auth integration', () => { - let listener - - beforeEach(() => { - listener = jest.fn() - - subscribe('csrf-token-update', listener) - }) - - afterEach(() => { - unsubscribe('csrf-token-update', listener) - }) - - test('fires off an event for @nextcloud/auth', () => { - setToken('123') - - expect(listener).toHaveBeenCalledWith({ token: '123' }) - }) - }) - -}) diff --git a/core/src/tests/OC/requesttoken.spec.ts b/core/src/tests/OC/requesttoken.spec.ts new file mode 100644 index 00000000000..8f92dbed153 --- /dev/null +++ b/core/src/tests/OC/requesttoken.spec.ts @@ -0,0 +1,147 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { setupServer } from 'msw/node' +import { http, HttpResponse } from 'msw' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchRequestToken, getRequestToken, setRequestToken } from '../../OC/requesttoken.ts' + +const eventbus = vi.hoisted(() => ({ emit: vi.fn() })) +vi.mock('@nextcloud/event-bus', () => eventbus) + +const server = setupServer() + +describe('getRequestToken', () => { + it('can read the token from DOM', () => { + mockToken('tokenmock-123') + expect(getRequestToken()).toBe('tokenmock-123') + }) + + it('can handle missing token', () => { + mockToken(undefined) + expect(getRequestToken()).toBeUndefined() + }) +}) + +describe('setRequestToken', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('does emit an event on change', () => { + setRequestToken('new-token') + expect(eventbus.emit).toBeCalledTimes(1) + expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' }) + }) + + it('does set the new token to the DOM', () => { + setRequestToken('new-token') + expect(document.head.dataset.requesttoken).toBe('new-token') + }) + + it('does remember the new token', () => { + mockToken('old-token') + setRequestToken('new-token') + expect(getRequestToken()).toBe('new-token') + }) + + it('throws if the token is not a string', () => { + // @ts-expect-error mocking + expect(() => setRequestToken(123)).toThrowError('Invalid CSRF token given') + }) + + it('throws if the token is not valid', () => { + expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given') + }) + + it('does not emit an event if the token is not valid', () => { + expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given') + expect(eventbus.emit).not.toBeCalled() + }) +}) + +describe('fetchRequestToken', () => { + const successfullCsrf = http.get('/index.php/csrftoken', () => { + return HttpResponse.json({ token: 'new-token' }) + }) + const forbiddenCsrf = http.get('/index.php/csrftoken', () => { + return HttpResponse.json([], { status: 403 }) + }) + const serverErrorCsrf = http.get('/index.php/csrftoken', () => { + return HttpResponse.json([], { status: 500 }) + }) + const networkErrorCsrf = http.get('/index.php/csrftoken', () => { + return new HttpResponse(null, { type: 'error' }) + }) + + beforeAll(() => { + server.listen() + }) + + beforeEach(() => { + vi.resetAllMocks() + }) + + it('correctly parses response', async () => { + server.use(successfullCsrf) + + mockToken('oldToken') + const token = await fetchRequestToken() + expect(token).toBe('new-token') + }) + + it('sets the token', async () => { + server.use(successfullCsrf) + + mockToken('oldToken') + await fetchRequestToken() + expect(getRequestToken()).toBe('new-token') + }) + + it('does emit an event', async () => { + server.use(successfullCsrf) + + await fetchRequestToken() + expect(eventbus.emit).toHaveBeenCalledOnce() + expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' }) + }) + + it('handles 403 error due to invalid cookies', async () => { + server.use(forbiddenCsrf) + + mockToken('oldToken') + await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API') + expect(getRequestToken()).toBe('oldToken') + }) + + it('handles server error', async () => { + server.use(serverErrorCsrf) + + mockToken('oldToken') + await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API') + expect(getRequestToken()).toBe('oldToken') + }) + + it('handles network error', async () => { + server.use(networkErrorCsrf) + + mockToken('oldToken') + await expect(() => fetchRequestToken()).rejects.toThrow() + expect(getRequestToken()).toBe('oldToken') + }) +}) + +/** + * Mock the request token directly so we can test reading it. + * + * @param token - The CSRF token to mock + */ +function mockToken(token?: string) { + if (token === undefined) { + delete document.head.dataset.requesttoken + } else { + document.head.dataset.requesttoken = token + } +} diff --git a/core/src/tests/OC/session-heartbeat.spec.ts b/core/src/tests/OC/session-heartbeat.spec.ts new file mode 100644 index 00000000000..61b82d92887 --- /dev/null +++ b/core/src/tests/OC/session-heartbeat.spec.ts @@ -0,0 +1,123 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const requestToken = vi.hoisted(() => ({ + fetchRequestToken: vi.fn<() => Promise<string>>(), + setRequestToken: vi.fn<(token: string) => void>(), +})) +vi.mock('../../OC/requesttoken.ts', () => requestToken) + +const initialState = vi.hoisted(() => ({ loadState: vi.fn() })) +vi.mock('@nextcloud/initial-state', () => initialState) + +describe('Session heartbeat', () => { + beforeAll(() => { + vi.useFakeTimers() + }) + + beforeEach(() => { + vi.clearAllTimers() + vi.resetModules() + vi.resetAllMocks() + }) + + it('sends heartbeat half the session lifetime when heartbeat enabled', async () => { + initialState.loadState.mockImplementationOnce(() => ({ + session_keepalive: true, + session_lifetime: 300, + })) + + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + // initial state loaded + expect(initialState.loadState).toBeCalledWith('core', 'config', {}) + + // less than half, still nothing + await vi.advanceTimersByTimeAsync(100 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // reach past half, one call + await vi.advanceTimersByTimeAsync(60 * 1000) + expect(requestToken.fetchRequestToken).toBeCalledTimes(1) + + // almost there to the next, still one + await vi.advanceTimersByTimeAsync(135 * 1000) + expect(requestToken.fetchRequestToken).toBeCalledTimes(1) + + // past it, second call + await vi.advanceTimersByTimeAsync(5 * 1000) + expect(requestToken.fetchRequestToken).toBeCalledTimes(2) + }) + + it('does not send heartbeat when heartbeat disabled', async () => { + initialState.loadState.mockImplementationOnce(() => ({ + session_keepalive: false, + session_lifetime: 300, + })) + + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + // initial state loaded + expect(initialState.loadState).toBeCalledWith('core', 'config', {}) + + // less than half, still nothing + await vi.advanceTimersByTimeAsync(100 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // more than one, still nothing + await vi.advanceTimersByTimeAsync(300 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + }) + + it('limit heartbeat to at least one minute', async () => { + initialState.loadState.mockImplementationOnce(() => ({ + session_keepalive: true, + session_lifetime: 55, + })) + + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + // initial state loaded + expect(initialState.loadState).toBeCalledWith('core', 'config', {}) + + // 30 / 55 seconds + await vi.advanceTimersByTimeAsync(30 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // 59 / 55 seconds should not be called except it does not limit + await vi.advanceTimersByTimeAsync(29 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // now one minute has passed + await vi.advanceTimersByTimeAsync(1000) + expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce() + }) + + it('limit heartbeat to at least one minute', async () => { + initialState.loadState.mockImplementationOnce(() => ({ + session_keepalive: true, + session_lifetime: 50 * 60 * 60, + })) + + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + // initial state loaded + expect(initialState.loadState).toBeCalledWith('core', 'config', {}) + + // 23 hours + await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // one day - it should be called now + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) + expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce() + }) +}) diff --git a/core/src/tests/components/ContactsMenu/Contact.spec.js b/core/src/tests/components/ContactsMenu/Contact.spec.js new file mode 100644 index 00000000000..e83f75bfd15 --- /dev/null +++ b/core/src/tests/components/ContactsMenu/Contact.spec.js @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest' +import { shallowMount } from '@vue/test-utils' + +import Contact from '../../../components/ContactsMenu/Contact.vue' + +describe('Contact', function() { + it('links to the top action', () => { + const view = shallowMount(Contact, { + propsData: { + contact: { + id: null, + fullName: 'Acosta Lancaster', + topAction: { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:deboraoliver%40centrexin.com', + }, + emailAddresses: [], + actions: [ + { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:mathisholland%40virxo.com', + }, + { + title: 'Details', + icon: 'icon-info', + hyperlink: 'https://localhost/index.php/apps/contacts', + }, + ], + lastMessage: '', + }, + }, + }) + + expect(view.find('li a').exists()).toBe(true) + expect(view.find('li a').attributes('href')).toBe('mailto:deboraoliver%40centrexin.com') + }) +}) diff --git a/core/src/tests/views/ContactsMenu.spec.js b/core/src/tests/views/ContactsMenu.spec.js new file mode 100644 index 00000000000..084c3215e47 --- /dev/null +++ b/core/src/tests/views/ContactsMenu.spec.js @@ -0,0 +1,143 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mount, shallowMount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +import ContactsMenu from '../../views/ContactsMenu.vue' + +const axios = vi.hoisted(() => ({ + post: vi.fn(), +})) +vi.mock('@nextcloud/axios', () => ({ default: axios })) + +vi.mock('@nextcloud/auth', () => ({ + getCurrentUser: () => ({ uid: 'user', isAdmin: false, displayName: 'User' }), +})) + +describe('ContactsMenu', function() { + it('is closed by default', () => { + const view = shallowMount(ContactsMenu) + + expect(view.vm.contacts).toEqual([]) + expect(view.vm.loadingText).toBe(undefined) + }) + + it('shows a loading text', async () => { + const view = shallowMount(ContactsMenu) + axios.post.mockResolvedValue({ + data: { + contacts: [], + contactsAppEnabled: false, + }, + }) + + const opening = view.vm.handleOpen() + + expect(view.vm.contacts).toEqual([]) + expect(view.vm.loadingText).toBe('Loading your contacts …') + await opening + }) + + it('shows error view when contacts can not be loaded', async () => { + const view = mount(ContactsMenu) + axios.post.mockResolvedValue({}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + await view.vm.handleOpen() + + throw new Error('should not be reached') + } catch (error) { + expect(console.error).toHaveBeenCalled() + console.error.mockRestore() + expect(view.vm.error).toBe(true) + expect(view.vm.contacts).toEqual([]) + expect(view.text()).toContain('Could not load your contacts') + } + }) + + it('shows text when there are no contacts', async () => { + const view = mount(ContactsMenu) + axios.post.mockResolvedValueOnce({ + data: { + contacts: [], + contactsAppEnabled: false, + }, + }) + + await view.vm.handleOpen() + + expect(view.vm.error).toBe(false) + expect(view.vm.contacts).toEqual([]) + expect(view.vm.loadingText).toBe(undefined) + expect(view.text()).toContain('No contacts found') + }) + + it('shows contacts', async () => { + const view = mount(ContactsMenu) + axios.post.mockResolvedValue({ + data: { + contacts: [ + { + id: null, + fullName: 'Acosta Lancaster', + topAction: { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:deboraoliver%40centrexin.com', + }, + actions: [ + { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:mathisholland%40virxo.com', + }, + { + title: 'Details', + icon: 'icon-info', + hyperlink: 'https://localhost/index.php/apps/contacts', + }, + ], + lastMessage: '', + emailAddresses: [], + }, + { + id: null, + fullName: 'Adeline Snider', + topAction: { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:ceciliasoto%40essensia.com', + }, + actions: [ + { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:pearliesellers%40inventure.com', + }, + { + title: 'Details', + icon: 'icon-info', + hyperlink: 'https://localhost/index.php/apps/contacts', + }, + ], + lastMessage: 'cu', + emailAddresses: [], + }, + ], + contactsAppEnabled: true, + }, + }) + + await view.vm.handleOpen() + + expect(view.vm.error).toBe(false) + expect(view.vm.contacts.length).toBe(2) + expect(view.text()).toContain('Acosta Lancaster') + expect(view.text()).toContain('Adeline Snider') + expect(view.text()).toContain('Show all contacts') + }) +}) |