diff options
Diffstat (limited to 'cypress/e2e')
94 files changed, 8780 insertions, 1411 deletions
diff --git a/cypress/e2e/core-utils.ts b/cypress/e2e/core-utils.ts index d2209da460a..4756836387a 100644 --- a/cypress/e2e/core-utils.ts +++ b/cypress/e2e/core-utils.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ /** @@ -65,3 +48,43 @@ export enum UnifiedSearchFilter { export function getUnifiedSearchFilter(filter: UnifiedSearchFilter) { return getUnifiedSearchModal().find(`[data-cy-unified-search-filters] [data-cy-unified-search-filter="${CSS.escape(filter)}"]`) } + +/** + * Assertion that an element is fully within the current viewport. + * @param $el The element + * @param expected If the element is expected to be fully in viewport or not fully + * @example + * ```js + * cy.get('#my-element') + * .should(beFullyInViewport) + * ``` + */ +export function beFullyInViewport($el: JQuery<HTMLElement>, expected = true) { + const { top, left, bottom, right } = $el.get(0)!.getBoundingClientRect() + const innerHeight = Cypress.$('body').innerHeight()! + const innerWidth = Cypress.$('body').innerWidth()! + const fullyVisible = top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth + + console.debug(`fullyVisible: ${fullyVisible}, top: ${top >= 0}, left: ${left >= 0}, bottom: ${bottom <= innerHeight}, right: ${right <= innerWidth}`) + + if (expected) { + // eslint-disable-next-line no-unused-expressions + expect(fullyVisible, 'Fully within viewport').to.be.true + } else { + // eslint-disable-next-line no-unused-expressions + expect(fullyVisible, 'Not fully within viewport').to.be.false + } +} + +/** + * Opposite of `beFullyInViewport` - resolves when element is not or only partially in viewport. + * @param $el The element + * @example + * ```js + * cy.get('#my-element') + * .should(notBeFullyInViewport) + * ``` + */ +export function notBeFullyInViewport($el: JQuery<HTMLElement>) { + return beFullyInViewport($el, false) +} diff --git a/cypress/e2e/core/404-error.cy.ts b/cypress/e2e/core/404-error.cy.ts new file mode 100644 index 00000000000..b24562933e8 --- /dev/null +++ b/cypress/e2e/core/404-error.cy.ts @@ -0,0 +1,19 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +describe('404 error page', { testIsolation: true }, () => { + it('renders 404 page', () => { + cy.visit('/doesnotexist', { failOnStatusCode: false }) + + cy.findByRole('heading', { name: /Page not found/ }) + .should('be.visible') + cy.findByRole('link', { name: /Back to Nextcloud/ }) + .should('be.visible') + .click() + + cy.url() + .should('match', /(\/index.php)\/login$/) + }) +}) diff --git a/cypress/e2e/core/header_access-levels.cy.ts b/cypress/e2e/core/header_access-levels.cy.ts index d1529376cf9..b0e9ab8bac1 100644 --- a/cypress/e2e/core/header_access-levels.cy.ts +++ b/cypress/e2e/core/header_access-levels.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { User } from '@nextcloud/cypress' @@ -108,7 +91,7 @@ describe('Header: Ensure regular users do not have admin settings in the Setting // I see that the "Apps" item in the Settings menu is shown cy.contains('li', 'Apps').should('be.visible') // I see that the "Users" item in the Settings menu is shown - cy.contains('li', 'Users').should('be.visible') + cy.contains('li', 'Accounts').should('be.visible') // I see that the "Help" item in the Settings menu is shown cy.contains('li', 'Help').should('be.visible') // I see that the "Log out" item in the Settings menu is shown diff --git a/cypress/e2e/core/header_contacts-menu.cy.ts b/cypress/e2e/core/header_contacts-menu.cy.ts index d4c8ffe7b1b..6279b72a78d 100644 --- a/cypress/e2e/core/header_contacts-menu.cy.ts +++ b/cypress/e2e/core/header_contacts-menu.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { User } from '@nextcloud/cypress' diff --git a/cypress/e2e/core/setup.ts b/cypress/e2e/core/setup.ts new file mode 100644 index 00000000000..a9174a3ebe7 --- /dev/null +++ b/cypress/e2e/core/setup.ts @@ -0,0 +1,145 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * DO NOT RENAME THIS FILE to .cy.ts ⚠️ + * This is not following the pattern of the other files in this folder + * because it is manually added to the tests by the cypress config. + */ +describe('Can install Nextcloud', { testIsolation: true, retries: 0 }, () => { + beforeEach(() => { + // Move the config file and data folder + cy.runCommand('rm /var/www/html/config/config.php', { failOnNonZeroExit: false }) + cy.runCommand('rm /var/www/html/data/owncloud.db', { failOnNonZeroExit: false }) + }) + + it('Sqlite', () => { + cy.visit('/') + cy.get('[data-cy-setup-form]').should('be.visible') + cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') + cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') + cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') + + // Select the SQLite database + cy.get('[data-cy-setup-form-field="dbtype-sqlite"] input').check({ force: true }) + + sharedSetup() + }) + + it('MySQL', () => { + cy.visit('/') + cy.get('[data-cy-setup-form]').should('be.visible') + cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') + cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') + cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') + + // Select the SQLite database + cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').check({ force: true }) + + // Fill in the DB form + cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}oc_autotest') + cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}nextcloud') + cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}oc_autotest') + cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}mysql:3306') + + sharedSetup() + }) + + it('MariaDB', () => { + cy.visit('/') + cy.get('[data-cy-setup-form]').should('be.visible') + cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') + cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') + cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') + + // Select the SQLite database + cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').check({ force: true }) + + // Fill in the DB form + cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}oc_autotest') + cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}nextcloud') + cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}oc_autotest') + cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}mariadb:3306') + + sharedSetup() + }) + + it('PostgreSQL', () => { + cy.visit('/') + cy.get('[data-cy-setup-form]').should('be.visible') + cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') + cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') + cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') + + // Select the SQLite database + cy.get('[data-cy-setup-form-field="dbtype-pgsql"] input').check({ force: true }) + + // Fill in the DB form + cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}root') + cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}rootpassword') + cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}nextcloud') + cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}postgres:5432') + + sharedSetup() + }) + + it('Oracle', () => { + cy.runCommand('cp /var/www/html/tests/databases-all-config.php /var/www/html/config/config.php') + cy.visit('/') + cy.get('[data-cy-setup-form]').should('be.visible') + cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') + cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') + cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') + + // Select the SQLite database + cy.get('[data-cy-setup-form-field="dbtype-oci"] input').check({ force: true }) + + // Fill in the DB form + cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}system') + cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}oracle') + cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}FREE') + cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}oracle:1521') + + sharedSetup() + }) + +}) + +/** + * Shared admin setup function for the Nextcloud setup + */ +function sharedSetup() { + const randAdmin = 'admin-' + Math.random().toString(36).substring(2, 15) + + // Fill in the form + cy.get('[data-cy-setup-form-field="adminlogin"]').type(randAdmin) + cy.get('[data-cy-setup-form-field="adminpass"]').type(randAdmin) + + // Nothing more to do on sqlite, let's continue + cy.get('[data-cy-setup-form-submit]').click() + + // Wait for the setup to finish + cy.location('pathname', { timeout: 10000 }) + .should('include', '/core/apps/recommended') + + // See the apps setup + cy.get('[data-cy-setup-recommended-apps]') + .should('be.visible') + .within(() => { + cy.findByRole('heading', { name: 'Recommended apps' }) + .should('be.visible') + cy.findByRole('button', { name: 'Skip' }) + .should('be.visible') + cy.findByRole('button', { name: 'Install recommended apps' }) + .should('be.visible') + }) + + // Skip the setup apps + cy.get('[data-cy-setup-recommended-apps-skip]').click() + + // Go to files + cy.visit('/apps/files/') + cy.get('[data-cy-files-content]').should('be.visible') +} diff --git a/cypress/e2e/dashboard/widget-performance.cy.ts b/cypress/e2e/dashboard/widget-performance.cy.ts new file mode 100644 index 00000000000..99e46d7b0ae --- /dev/null +++ b/cypress/e2e/dashboard/widget-performance.cy.ts @@ -0,0 +1,41 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Regression test of https://github.com/nextcloud/server/issues/48403 + * Ensure that only visible widget data is loaded + */ +describe('dashboard: performance', () => { + before(() => { + cy.createRandomUser().then((user) => { + // Enable one widget + cy.runOccCommand(`user:setting -- '${user.userId}' dashboard layout files-favorites`) + cy.login(user) + }) + }) + + it('Only load needed widgets', () => { + cy.intercept('**/dashboard/api/v2/widget-items?widgets*').as('loadedWidgets') + + const now = new Date(2025, 0, 14, 15) + cy.clock(now) + + // The dashboard is loaded + cy.visit('/apps/dashboard') + cy.get('#app-dashboard') + .should('be.visible') + .contains('Good afternoon') + .should('be.visible') + + // Wait that one data is loaded (ensure the API works), this should be the favorite files. + cy.wait('@loadedWidgets') + // Wait and check no requests are made (ensure that the user statuses data is NOT loaded) + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(4000, { timeout: 8000 }) + cy.get('@loadedWidgets.all').then((interceptions) => { + expect(interceptions).to.have.length(1) + }) + }) +}) diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index b23d9997390..71ea341a7bf 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -1,57 +1,122 @@ /** - * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { User } from '@nextcloud/cypress' +import { ACTION_COPY_MOVE } from '../../../apps/files/src/actions/moveOrCopyAction.ts' + export const getRowForFileId = (fileid: number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"]`) export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"]`) export const getActionsForFileId = (fileid: number) => getRowForFileId(fileid).find('[data-cy-files-list-row-actions]') export const getActionsForFile = (filename: string) => getRowForFile(filename).find('[data-cy-files-list-row-actions]') -export const getActionButtonForFileId = (fileid: number) => getActionsForFileId(fileid).find('button[aria-label="Actions"]') -export const getActionButtonForFile = (filename: string) => getActionsForFile(filename).find('button[aria-label="Actions"]') +export const getActionButtonForFileId = (fileid: number) => getActionsForFileId(fileid).findByRole('button', { name: 'Actions' }) +export const getActionButtonForFile = (filename: string) => getActionsForFile(filename).findByRole('button', { name: 'Actions' }) + +export const getActionEntryForFileId = (fileid: number, actionId: string) => { + return getActionButtonForFileId(fileid) + .should('have.attr', 'aria-controls') + .then((menuId) => cy.get(`#${menuId}`).find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)) +} + +export const getActionEntryForFile = (file: string, actionId: string) => { + return getActionButtonForFile(file) + .should('have.attr', 'aria-controls') + .then((menuId) => cy.get(`#${menuId}`).find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)) +} + +export const getInlineActionEntryForFileId = (fileid: number, actionId: string) => { + return getActionsForFileId(fileid) + .find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) +} + +export const getInlineActionEntryForFile = (file: string, actionId: string) => { + return getActionsForFile(file) + .find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) +} export const triggerActionForFileId = (fileid: number, actionId: string) => { - getActionButtonForFileId(fileid).click() - cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).should('exist').click() + getActionButtonForFileId(fileid) + .as('actionButton') + .scrollIntoView() + cy.get('@actionButton') + .click({ force: true }) // force to avoid issues with overlaying file list header + getActionEntryForFileId(fileid, actionId) + .find('button') + .should('be.visible') + .click() } + export const triggerActionForFile = (filename: string, actionId: string) => { - getActionButtonForFile(filename).click() - cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).should('exist').click() + getActionButtonForFile(filename) + .as('actionButton') + .scrollIntoView() + cy.get('@actionButton') + .click({ force: true }) // force to avoid issues with overlaying file list header + getActionEntryForFile(filename, actionId) + .find('button') + .should('be.visible') + .click() } export const triggerInlineActionForFileId = (fileid: number, actionId: string) => { - getActionsForFileId(fileid).find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`).should('exist').click() + getActionsForFileId(fileid) + .find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) + .should('exist') + .click() } export const triggerInlineActionForFile = (filename: string, actionId: string) => { - getActionsForFile(filename).get(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`).should('exist').click() + getActionsForFile(filename) + .find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) + .should('exist') + .click() +} + +export const selectAllFiles = () => { + cy.get('[data-cy-files-list-selection-checkbox]') + .findByRole('checkbox', { checked: false }) + .click({ force: true }) +} +export const deselectAllFiles = () => { + cy.get('[data-cy-files-list-selection-checkbox]') + .findByRole('checkbox', { checked: true }) + .click({ force: true }) +} + +export const selectRowForFile = (filename: string, options: Partial<Cypress.ClickOptions> = {}) => { + getRowForFile(filename) + .find('[data-cy-files-list-row-checkbox]') + .findByRole('checkbox') + // don't use click to avoid triggering side effects events + .trigger('change', { ...options, force: true }) + .should('be.checked') + cy.get('[data-cy-files-list-selection-checkbox]').findByRole('checkbox').should('satisfy', (elements) => { + return elements.length === 1 && (elements[0].checked === true || elements[0].indeterminate === true) + }) + +} + +export const getSelectionActionButton = () => cy.get('[data-cy-files-list-selection-actions]').findByRole('button', { name: 'Actions' }) +export const getSelectionActionEntry = (actionId: string) => cy.get(`[data-cy-files-list-selection-action="${CSS.escape(actionId)}"]`) +export const triggerSelectionAction = (actionId: string) => { + // Even if it's inline, we open the action menu to get all actions visible + getSelectionActionButton().click({ force: true }) + // the entry might already be a button or a button might its child + getSelectionActionEntry(actionId) + .then($el => $el.is('button') ? cy.wrap($el) : cy.wrap($el).findByRole('button').last()) + .should('exist') + .click() } export const moveFile = (fileName: string, dirPath: string) => { getRowForFile(fileName).should('be.visible') - triggerActionForFile(fileName, 'move-copy') + triggerActionForFile(fileName, ACTION_COPY_MOVE) cy.get('.file-picker').within(() => { // intercept the copy so we can wait for it - cy.intercept('MOVE', /\/remote.php\/dav\/files\//).as('moveFile') + cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile') if (dirPath === '/') { // select home folder @@ -78,11 +143,11 @@ export const moveFile = (fileName: string, dirPath: string) => { export const copyFile = (fileName: string, dirPath: string) => { getRowForFile(fileName).should('be.visible') - triggerActionForFile(fileName, 'move-copy') + triggerActionForFile(fileName, ACTION_COPY_MOVE) cy.get('.file-picker').within(() => { // intercept the copy so we can wait for it - cy.intercept('COPY', /\/remote.php\/dav\/files\//).as('copyFile') + cy.intercept('COPY', /\/(remote|public)\.php\/dav\/files\//).as('copyFile') if (dirPath === '/') { // select home folder @@ -109,28 +174,36 @@ export const copyFile = (fileName: string, dirPath: string) => { export const renameFile = (fileName: string, newFileName: string) => { getRowForFile(fileName) + .should('exist') + .scrollIntoView() + triggerActionForFile(fileName, 'rename') // intercept the move so we can wait for it - cy.intercept('MOVE', /\/remote.php\/dav\/files\//).as('moveFile') + cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile') - getRowForFile(fileName).find('[data-cy-files-list-row-name] input').clear() - getRowForFile(fileName).find('[data-cy-files-list-row-name] input').type(`${newFileName}{enter}`) + getRowForFile(fileName) + .find('[data-cy-files-list-row-name] input') + .type(`{selectAll}${newFileName}{enter}`) cy.wait('@moveFile') } export const navigateToFolder = (dirPath: string) => { const directories = dirPath.split('/') - directories.forEach((directory) => { + for (const directory of directories) { + if (directory === '') { + continue + } + getRowForFile(directory).should('be.visible').find('[data-cy-files-list-row-name-link]').click() - }) + } } export const closeSidebar = () => { // {force: true} as it might be hidden behind toasts - cy.get('[cy-data-sidebar] .app-sidebar__close').click({ force: true }) + cy.get('[data-cy-sidebar] .app-sidebar__close').click({ force: true }) } export const clickOnBreadcrumbs = (label: string) => { @@ -138,3 +211,114 @@ export const clickOnBreadcrumbs = (label: string) => { cy.get('[data-cy-files-content-breadcrumbs]').contains(label).click() cy.wait('@propfind') } + +export const createFolder = (folderName: string) => { + cy.intercept('MKCOL', /\/remote.php\/dav\/files\//).as('createFolder') + + // TODO: replace by proper data-cy selectors + cy.get('[data-cy-upload-picker] .action-item__menutoggle').first().click() + cy.get('[data-cy-upload-picker-menu-entry="newFolder"] button').click() + cy.get('[data-cy-files-new-node-dialog]').should('be.visible') + cy.get('[data-cy-files-new-node-dialog-input]').type(`{selectall}${folderName}`) + cy.get('[data-cy-files-new-node-dialog-submit]').click() + + cy.wait('@createFolder') + + getRowForFile(folderName).should('be.visible') +} + +/** + * Check validity of an input element + * @param validity The expected validity message (empty string means it is valid) + * @example + * ```js + * cy.findByRole('textbox') + * .should(haveValidity(/must not be empty/i)) + * ``` + */ +export const haveValidity = (validity: string | RegExp) => { + if (typeof validity === 'string') { + return (el: JQuery<HTMLElement>) => expect((el.get(0) as HTMLInputElement).validationMessage).to.equal(validity) + } + return (el: JQuery<HTMLElement>) => expect((el.get(0) as HTMLInputElement).validationMessage).to.match(validity) +} + +export const deleteFileWithRequest = (user: User, path: string) => { + // Ensure path starts with a slash and has no double slashes + path = `/${path}`.replace(/\/+/g, '/') + + cy.request('/csrftoken').then(({ body }) => { + const requestToken = body.token + cy.request({ + method: 'DELETE', + url: `${Cypress.env('baseUrl')}/remote.php/dav/files/${user.userId}${path}`, + auth: { + user: user.userId, + password: user.password, + }, + headers: { + requestToken, + }, + retryOnStatusCodeFailure: true, + }) + }) +} + +export const triggerFileListAction = (actionId: string) => { + cy.get(`button[data-cy-files-list-action="${CSS.escape(actionId)}"]`).last() + .should('exist').click({ force: true }) +} + +export const reloadCurrentFolder = () => { + cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind') + cy.get('[data-cy-files-content-breadcrumbs]').findByRole('button', { description: 'Reload current directory' }).click() + cy.wait('@propfind') +} + +/** + * Enable the grid mode for the files list. + * Will fail if already enabled! + */ +export function enableGridMode() { + cy.intercept('**/apps/files/api/v1/config/grid_view').as('setGridMode') + cy.findByRole('button', { name: 'Switch to grid view' }) + .should('be.visible') + .click() + cy.wait('@setGridMode') +} + +/** + * Calculate the needed viewport height to limit the visible rows of the file list. + * Requires a logged in user. + * + * @param rows The number of rows that should be displayed at the same time + */ +export function calculateViewportHeight(rows: number): Cypress.Chainable<number> { + cy.visit('/apps/files') + + cy.get('[data-cy-files-list]') + .should('be.visible') + + cy.get('[data-cy-files-list-tbody] tr', { timeout: 5000 }) + .and('be.visible') + + return cy.get('[data-cy-files-list]') + .should('be.visible') + .then((filesList) => { + const windowHeight = Cypress.$('body').outerHeight()! + // Size of other page elements + const outerHeight = Math.ceil(windowHeight - filesList.outerHeight()!) + // Size of before and filters + const beforeHeight = Math.ceil(Cypress.$('.files-list__before').outerHeight()!) + const filterHeight = Math.ceil(Cypress.$('.files-list__filters').outerHeight()!) + // Size of the table header + const tableHeaderHeight = Math.ceil(Cypress.$('[data-cy-files-list-thead]').outerHeight()!) + // table row height + const rowHeight = Math.ceil(Cypress.$('[data-cy-files-list-tbody] tr').outerHeight()!) + + // sum it up + const viewportHeight = outerHeight + beforeHeight + filterHeight + tableHeaderHeight + rows * rowHeight + cy.log(`Calculated viewport height: ${viewportHeight} (${outerHeight} + ${beforeHeight} + ${filterHeight} + ${tableHeaderHeight} + ${rows} * ${rowHeight})`) + return cy.wrap(viewportHeight) + }) +} diff --git a/cypress/e2e/files/LivePhotosUtils.ts b/cypress/e2e/files/LivePhotosUtils.ts new file mode 100644 index 00000000000..34e6a1d934e --- /dev/null +++ b/cypress/e2e/files/LivePhotosUtils.ts @@ -0,0 +1,104 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' + +type SetupInfo = { + snapshot: string + jpgFileId: number + movFileId: number + fileName: string + user: User +} + +/** + */ +function setMetadata(user: User, fileName: string, requesttoken: string, metadata: object) { + const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?/, '') + cy.request({ + method: 'PROPPATCH', + url: `${base}/remote.php/dav/files/${user.userId}/${fileName}`, + auth: { user: user.userId, pass: user.password }, + headers: { + requesttoken, + }, + body: `<?xml version="1.0"?> + <d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns"> + <d:set> + <d:prop> + ${Object.entries(metadata).map(([key, value]) => `<${key}>${value}</${key}>`).join('\n')} + </d:prop> + </d:set> + </d:propertyupdate>`, + }) +} + +/** + * + * @param enable + */ +export function setShowHiddenFiles(enable: boolean) { + cy.request('/csrftoken').then(({ body }) => { + const requestToken = body.token + const url = `${Cypress.config('baseUrl')}/apps/files/api/v1/config/show_hidden` + cy.request({ + method: 'PUT', + url, + headers: { + 'Content-Type': 'application/json', + requesttoken: requestToken, + }, + body: { value: enable }, + }) + }) + cy.reload() +} + +/** + * + */ +export function setupLivePhotos(): Cypress.Chainable<SetupInfo> { + return cy.task('getVariable', { key: 'live-photos-data' }) + .then((_setupInfo) => { + const setupInfo = _setupInfo as SetupInfo || {} + if (setupInfo.snapshot) { + cy.restoreState(setupInfo.snapshot) + } else { + let requesttoken: string + + setupInfo.fileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + + cy.createRandomUser().then(_user => { setupInfo.user = _user }) + + cy.then(() => { + cy.uploadContent(setupInfo.user, new Blob(['jpg file'], { type: 'image/jpg' }), 'image/jpg', `/${setupInfo.fileName}.jpg`) + .then(response => { setupInfo.jpgFileId = parseInt(response.headers['oc-fileid']) }) + cy.uploadContent(setupInfo.user, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/${setupInfo.fileName}.mov`) + .then(response => { setupInfo.movFileId = parseInt(response.headers['oc-fileid']) }) + + cy.login(setupInfo.user) + }) + + cy.visit('/apps/files') + + cy.get('head').invoke('attr', 'data-requesttoken').then(_requesttoken => { requesttoken = _requesttoken as string }) + + cy.then(() => { + setMetadata(setupInfo.user, `${setupInfo.fileName}.jpg`, requesttoken, { 'nc:metadata-files-live-photo': setupInfo.movFileId }) + setMetadata(setupInfo.user, `${setupInfo.fileName}.mov`, requesttoken, { 'nc:metadata-files-live-photo': setupInfo.jpgFileId }) + }) + + cy.then(() => { + cy.saveState().then((value) => { setupInfo.snapshot = value }) + cy.task('setVariable', { key: 'live-photos-data', value: setupInfo }) + }) + } + return cy.then(() => { + cy.login(setupInfo.user) + cy.visit('/apps/files') + return cy.wrap(setupInfo) + }) + }) +} diff --git a/cypress/e2e/files/drag-n-drop.cy.ts b/cypress/e2e/files/drag-n-drop.cy.ts index 86a3bcfb571..d8df1938694 100644 --- a/cypress/e2e/files/drag-n-drop.cy.ts +++ b/cypress/e2e/files/drag-n-drop.cy.ts @@ -1,3 +1,7 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ import { getRowForFile } from './FilesUtils.ts' describe('files: Drag and Drop', { testIsolation: true }, () => { diff --git a/cypress/e2e/files/duplicated-node-regression.cy.ts b/cypress/e2e/files/duplicated-node-regression.cy.ts new file mode 100644 index 00000000000..14355a62b9d --- /dev/null +++ b/cypress/e2e/files/duplicated-node-regression.cy.ts @@ -0,0 +1,33 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createFolder, getRowForFile, triggerActionForFile } from './FilesUtils.ts' + +before(() => { + cy.createRandomUser() + .then((user) => { + cy.mkdir(user, '/only once') + cy.login(user) + cy.visit('/apps/files') + }) +}) + +/** + * Regression test for https://github.com/nextcloud/server/issues/47904 + */ +it('Ensure nodes are not duplicated in the file list', () => { + // See the folder + getRowForFile('only once').should('be.visible') + // Delete the folder + cy.intercept('DELETE', '**/remote.php/dav/**').as('deleteFolder') + triggerActionForFile('only once', 'delete') + cy.wait('@deleteFolder') + getRowForFile('only once').should('not.exist') + // Create the folder again + createFolder('only once') + // See folder exists only once + getRowForFile('only once') + .should('have.length', 1) +}) diff --git a/cypress/e2e/files/favorites.cy.ts b/cypress/e2e/files/favorites.cy.ts new file mode 100644 index 00000000000..96812f116e1 --- /dev/null +++ b/cypress/e2e/files/favorites.cy.ts @@ -0,0 +1,137 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { getActionButtonForFile, getRowForFile, triggerActionForFile } from './FilesUtils' + +describe('files: Favorites', { testIsolation: true }, () => { + let user: User + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') + cy.mkdir(user, '/new folder') + cy.login(user) + cy.visit('/apps/files') + }) + }) + + it('Mark file as favorite', () => { + // See file exists + getRowForFile('file.txt') + .should('exist') + + cy.intercept('POST', '**/apps/files/api/v1/files/file.txt').as('addToFavorites') + // Click actions + getActionButtonForFile('file.txt').click({ force: true }) + // See action is called 'Add to favorites' + cy.get('[data-cy-files-list-row-action="favorite"] > button').last() + .should('exist') + .and('contain.text', 'Add to favorites') + .click({ force: true }) + cy.wait('@addToFavorites') + // See favorites star + getRowForFile('file.txt') + .findByRole('img', { name: 'Favorite' }) + .should('exist') + }) + + it('Un-mark file as favorite', () => { + // See file exists + getRowForFile('file.txt') + .should('exist') + + cy.intercept('POST', '**/apps/files/api/v1/files/file.txt').as('addToFavorites') + // toggle favorite + triggerActionForFile('file.txt', 'favorite') + cy.wait('@addToFavorites') + + // See favorites star + getRowForFile('file.txt') + .findByRole('img', { name: 'Favorite' }) + .should('be.visible') + + // Remove favorite + // click action button + getActionButtonForFile('file.txt').click({ force: true }) + // See action is called 'Remove from favorites' + cy.get('[data-cy-files-list-row-action="favorite"] > button').last() + .should('exist') + .and('have.text', 'Remove from favorites') + .click({ force: true }) + cy.wait('@addToFavorites') + // See no favorites star anymore + getRowForFile('file.txt') + .findByRole('img', { name: 'Favorite' }) + .should('not.exist') + }) + + it('See favorite folders in navigation', () => { + cy.intercept('POST', '**/apps/files/api/v1/files/new%20folder').as('addToFavorites') + + // see navigation has no entry + cy.get('[data-cy-files-navigation-item="favorites"]') + .should('be.visible') + .contains('new folder') + .should('not.exist') + + // toggle favorite + triggerActionForFile('new folder', 'favorite') + cy.wait('@addToFavorites') + + // See in navigation + cy.get('[data-cy-files-navigation-item="favorites"]') + .should('be.visible') + .contains('new folder') + .should('exist') + + // toggle favorite + triggerActionForFile('new folder', 'favorite') + cy.wait('@addToFavorites') + + // See no longer in navigation + cy.get('[data-cy-files-navigation-item="favorites"]') + .should('be.visible') + .contains('new folder') + .should('not.exist') + }) + + it('Mark file as favorite using the sidebar', () => { + // See file exists + getRowForFile('new folder') + .should('exist') + // see navigation has no entry + cy.get('[data-cy-files-navigation-item="favorites"]') + .should('be.visible') + .contains('new folder') + .should('not.exist') + + cy.intercept('PROPPATCH', '**/remote.php/dav/files/*/new%20folder').as('addToFavorites') + // open sidebar + triggerActionForFile('new folder', 'details') + // open actions + cy.get('[data-cy-sidebar]') + .findByRole('button', { name: 'Actions' }) + .click() + // trigger menu button + cy.findAllByRole('menu') + .findByRole('menuitem', { name: 'Add to favorites' }) + .should('be.visible') + .click() + cy.wait('@addToFavorites') + + // See favorites star + getRowForFile('new folder') + .findByRole('img', { name: 'Favorite' }) + .should('be.visible') + + // See folder in navigation + cy.get('[data-cy-files-navigation-item="favorites"]') + .should('be.visible') + .contains('new folder') + .should('exist') + }) +}) diff --git a/cypress/e2e/files/files-actions.cy.ts b/cypress/e2e/files/files-actions.cy.ts new file mode 100644 index 00000000000..dbcf810e2a2 --- /dev/null +++ b/cypress/e2e/files/files-actions.cy.ts @@ -0,0 +1,216 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { FileAction } from '@nextcloud/files' + +import { getActionButtonForFileId, getActionEntryForFileId, getRowForFile, getSelectionActionButton, getSelectionActionEntry, selectRowForFile } from './FilesUtils' +import { ACTION_COPY_MOVE } from '../../../apps/files/src/actions/moveOrCopyAction' +import { ACTION_DELETE } from '../../../apps/files/src/actions/deleteAction' +import { ACTION_DETAILS } from '../../../apps/files/src/actions/sidebarAction' + +declare global { + interface Window { + _nc_fileactions: FileAction[] + } +} + +// Those two arrays doesn't represent the full list of actions +// the goal is to test a few, we're not trying to match the full feature set +const expectedDefaultActionsIDs = [ + ACTION_COPY_MOVE, + ACTION_DELETE, + ACTION_DETAILS, +] +const expectedDefaultSelectionActionsIDs = [ + ACTION_COPY_MOVE, + ACTION_DELETE, +] + +describe('Files: Actions', { testIsolation: true }, () => { + let user: User + let fileId: number = 0 + + beforeEach(() => cy.createRandomUser().then(($user) => { + user = $user + + cy.uploadContent(user, new Blob([]), 'image/jpeg', '/image.jpg').then((response) => { + fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') + }) + cy.login(user) + })) + + it('Show some standard actions', () => { + cy.visit('/apps/files') + getRowForFile('image.jpg').should('be.visible') + + expectedDefaultActionsIDs.forEach((actionId) => { + // Open the menu + getActionButtonForFileId(fileId).click({ force: true }) + // Check the action is visible + getActionEntryForFileId(fileId, actionId).should('be.visible') + // Close the menu + cy.get('body').click({ force: true }) + }) + }) + + it('Show some nested actions', () => { + const parent = new FileAction({ + id: 'nested-action', + displayName: () => 'Nested Action', + exec: cy.spy(), + iconSvgInline: () => '<svg></svg>', + }) + + const child1 = new FileAction({ + id: 'nested-child-1', + displayName: () => 'Nested Child 1', + exec: cy.spy(), + iconSvgInline: () => '<svg></svg>', + parent: 'nested-action', + }) + + const child2 = new FileAction({ + id: 'nested-child-2', + displayName: () => 'Nested Child 2', + exec: cy.spy(), + iconSvgInline: () => '<svg></svg>', + parent: 'nested-action', + }) + + cy.visit('/apps/files', { + // Cannot use registerFileAction here + onBeforeLoad: (win) => { + if (!win._nc_fileactions) win._nc_fileactions = [] + // Cannot use registerFileAction here + win._nc_fileactions.push(parent) + win._nc_fileactions.push(child1) + win._nc_fileactions.push(child2) + }, + }) + + // Open the menu + getActionButtonForFileId(fileId) + .scrollIntoView() + .click({ force: true }) + + // Check we have the parent action but not the children + getActionEntryForFileId(fileId, 'nested-action').should('be.visible') + getActionEntryForFileId(fileId, 'menu-back').should('not.exist') + getActionEntryForFileId(fileId, 'nested-child-1').should('not.exist') + getActionEntryForFileId(fileId, 'nested-child-2').should('not.exist') + + // Click on the parent action + getActionEntryForFileId(fileId, 'nested-action') + .should('be.visible') + .click() + + // Check we have the children and the back button but not the parent + getActionEntryForFileId(fileId, 'nested-action').should('not.exist') + getActionEntryForFileId(fileId, 'menu-back').should('be.visible') + getActionEntryForFileId(fileId, 'nested-child-1').should('be.visible') + getActionEntryForFileId(fileId, 'nested-child-2').should('be.visible') + + // Click on the back button + getActionEntryForFileId(fileId, 'menu-back') + .should('be.visible') + .click() + + // Check we have the parent action but not the children + getActionEntryForFileId(fileId, 'nested-action').should('be.visible') + getActionEntryForFileId(fileId, 'menu-back').should('not.exist') + getActionEntryForFileId(fileId, 'nested-child-1').should('not.exist') + getActionEntryForFileId(fileId, 'nested-child-2').should('not.exist') + }) + + it('Show some actions for a selection', () => { + cy.visit('/apps/files') + getRowForFile('image.jpg').should('be.visible') + + selectRowForFile('image.jpg') + + cy.get('[data-cy-files-list-selection-actions]').should('be.visible') + getSelectionActionButton().should('be.visible') + + // Open the menu + getSelectionActionButton().click({ force: true }) + + // Check the action is visible + expectedDefaultSelectionActionsIDs.forEach((actionId) => { + getSelectionActionEntry(actionId).should('be.visible') + }) + }) + + it('Show some nested actions for a selection', () => { + const parent = new FileAction({ + id: 'nested-action', + displayName: () => 'Nested Action', + exec: cy.spy(), + iconSvgInline: () => '<svg></svg>', + }) + + const child1 = new FileAction({ + id: 'nested-child-1', + displayName: () => 'Nested Child 1', + exec: cy.spy(), + execBatch: cy.spy(), + iconSvgInline: () => '<svg></svg>', + parent: 'nested-action', + }) + + const child2 = new FileAction({ + id: 'nested-child-2', + displayName: () => 'Nested Child 2', + exec: cy.spy(), + execBatch: cy.spy(), + iconSvgInline: () => '<svg></svg>', + parent: 'nested-action', + }) + + cy.visit('/apps/files', { + // Cannot use registerFileAction here + onBeforeLoad: (win) => { + if (!win._nc_fileactions) win._nc_fileactions = [] + // Cannot use registerFileAction here + win._nc_fileactions.push(parent) + win._nc_fileactions.push(child1) + win._nc_fileactions.push(child2) + }, + }) + + selectRowForFile('image.jpg') + + // Open the menu + getSelectionActionButton().click({ force: true }) + + // Check we have the parent action but not the children + getSelectionActionEntry('nested-action').should('be.visible') + getSelectionActionEntry('menu-back').should('not.exist') + getSelectionActionEntry('nested-child-1').should('not.exist') + getSelectionActionEntry('nested-child-2').should('not.exist') + + // Click on the parent action + getSelectionActionEntry('nested-action') + .find('button').last() + .should('exist').click({ force: true }) + + // Check we have the children and the back button but not the parent + getSelectionActionEntry('nested-action').should('not.exist') + getSelectionActionEntry('menu-back').should('be.visible') + getSelectionActionEntry('nested-child-1').should('be.visible') + getSelectionActionEntry('nested-child-2').should('be.visible') + + // Click on the back button + getSelectionActionEntry('menu-back') + .find('button').last() + .should('exist').click({ force: true }) + + // Check we have the parent action but not the children + getSelectionActionEntry('nested-action').should('be.visible') + getSelectionActionEntry('menu-back').should('not.exist') + getSelectionActionEntry('nested-child-1').should('not.exist') + getSelectionActionEntry('nested-child-2').should('not.exist') + }) +}) diff --git a/cypress/e2e/files/files_copy-move.cy.ts b/cypress/e2e/files/files-copy-move.cy.ts index 7c80cb12ead..086248eef3c 100644 --- a/cypress/e2e/files/files_copy-move.cy.ts +++ b/cypress/e2e/files/files-copy-move.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { getRowForFile, moveFile, copyFile, navigateToFolder } from './FilesUtils.ts' diff --git a/cypress/e2e/files/files-delete.cy.ts b/cypress/e2e/files/files-delete.cy.ts new file mode 100644 index 00000000000..edb88519c59 --- /dev/null +++ b/cypress/e2e/files/files-delete.cy.ts @@ -0,0 +1,74 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { getRowForFile, navigateToFolder, selectAllFiles, triggerActionForFile } from './FilesUtils.ts' + +describe('files: Delete files using file actions', { testIsolation: true }, () => { + let user: User + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + }) + }) + + it('can delete file', () => { + cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') + cy.login(user) + cy.visit('/apps/files') + + // The file must exist and the preview loaded as it locks the file + getRowForFile('file.txt') + .should('be.visible') + .find('.files-list__row-icon-preview--loaded') + .should('exist') + + cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile') + + triggerActionForFile('file.txt', 'delete') + cy.wait('@deleteFile').its('response.statusCode').should('eq', 204) + }) + + it('can delete multiple files', () => { + cy.mkdir(user, '/root') + for (let i = 0; i < 5; i++) { + cy.uploadContent(user, new Blob([]), 'text/plain', `/root/file${i}.txt`) + } + cy.login(user) + cy.visit('/apps/files') + navigateToFolder('/root') + + // The file must exist and the preview loaded as it locks the file + cy.get('.files-list__row-icon-preview--loaded') + .should('have.length', 5) + + cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile') + + // select all + selectAllFiles() + cy.get('[data-cy-files-list-selection-actions]') + .findByRole('button', { name: 'Actions' }) + .click() + cy.get('[data-cy-files-list-selection-action="delete"]') + .findByRole('menuitem', { name: /^Delete files/ }) + .click() + + // see dialog for confirmation + cy.findByRole('dialog', { name: 'Confirm deletion' }) + .findByRole('button', { name: 'Delete files' }) + .click() + + cy.wait('@deleteFile') + cy.get('@deleteFile.all') + .should('have.length', 5) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .should((all: any) => { + for (const call of all) { + expect(call.response.statusCode).to.equal(204) + } + }) + }) +}) diff --git a/cypress/e2e/files/files-download.cy.ts b/cypress/e2e/files/files-download.cy.ts new file mode 100644 index 00000000000..06eb62094b8 --- /dev/null +++ b/cypress/e2e/files/files-download.cy.ts @@ -0,0 +1,351 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { getRowForFile, navigateToFolder, triggerActionForFile } from './FilesUtils' +import { deleteDownloadsFolderBeforeEach } from 'cypress-delete-downloads-folder' +import { zipFileContains } from '../../support/utils/assertions.ts' + +import randomString from 'crypto-random-string' + +describe('files: Download files using file actions', { testIsolation: true }, () => { + let user: User + + deleteDownloadsFolderBeforeEach() + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + }) + }) + + it('can download file', () => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt') + cy.login(user) + cy.visit('/apps/files') + + getRowForFile('file.txt').should('be.visible') + + triggerActionForFile('file.txt', 'download') + + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) + + it('can download folder', () => { + cy.mkdir(user, '/subfolder') + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/subfolder/file.txt') + + cy.login(user) + cy.visit('/apps/files') + getRowForFile('subfolder') + .should('be.visible') + + triggerActionForFile('subfolder', 'download') + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + 'subfolder/', + 'subfolder/file.txt', + ])) + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + it('can download file with hash name', () => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/#file.txt') + cy.login(user) + cy.visit('/apps/files') + + triggerActionForFile('#file.txt', 'download') + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/#file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + it('can download file from folder with hash name', () => { + cy.mkdir(user, '/#folder') + .uploadContent(user, new Blob(['<content>']), 'text/plain', '/#folder/file.txt') + cy.login(user) + cy.visit('/apps/files') + + navigateToFolder('#folder') + // All are visible by default + getRowForFile('file.txt').should('be.visible') + + triggerActionForFile('file.txt', 'download') + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) +}) + +describe('files: Download files using default action', { testIsolation: true }, () => { + let user: User + + deleteDownloadsFolderBeforeEach() + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + }) + }) + + it('can download file', () => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt') + cy.login(user) + cy.visit('/apps/files') + + getRowForFile('file.txt') + .should('be.visible') + .findByRole('button', { name: 'Download' }) + .click() + + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + it('can download file with hash name', () => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/#file.txt') + cy.login(user) + cy.visit('/apps/files') + + getRowForFile('#file.txt') + .should('be.visible') + .findByRole('button', { name: 'Download' }) + .click() + + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/#file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + it('can download file from folder with hash name', () => { + cy.mkdir(user, '/#folder') + .uploadContent(user, new Blob(['<content>']), 'text/plain', '/#folder/file.txt') + cy.login(user) + cy.visit('/apps/files') + + navigateToFolder('#folder') + // All are visible by default + getRowForFile('file.txt') + .should('be.visible') + .findByRole('button', { name: 'Download' }) + .click() + + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) +}) + +describe('files: Download files using selection', () => { + + deleteDownloadsFolderBeforeEach() + + it('can download selected files', () => { + cy.createRandomUser().then((user) => { + cy.mkdir(user, '/subfolder') + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/subfolder/file.txt') + cy.login(user) + cy.visit('/apps/files') + }) + + getRowForFile('subfolder') + .should('be.visible') + + getRowForFile('subfolder') + .findByRole('checkbox') + .check({ force: true }) + + // see that two files are selected + cy.get('[data-cy-files-list]').within(() => { + cy.contains('1 selected').should('be.visible') + }) + + // click download + cy.get('[data-cy-files-list-selection-actions]') + .findByRole('button', { name: 'Actions' }) + .click() + cy.findByRole('menuitem', { name: 'Download (selected)' }) + .should('be.visible') + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + 'subfolder/', + 'subfolder/file.txt', + ])) + }) + + it('can download multiple selected files', () => { + cy.createRandomUser().then((user) => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt') + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/other file.txt') + cy.login(user) + cy.visit('/apps/files') + }) + + getRowForFile('file.txt') + .should('be.visible') + .findByRole('checkbox') + .check({ force: true }) + + getRowForFile('other file.txt') + .should('be.visible') + .findByRole('checkbox') + .check({ force: true }) + + cy.get('[data-cy-files-list]').within(() => { + // see that two files are selected + cy.contains('2 selected').should('be.visible') + }) + + // click download + cy.get('[data-cy-files-list-selection-actions]') + .findByRole('button', { name: 'Actions' }) + .click() + cy.findByRole('menuitem', { name: 'Download (selected)' }) + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/download.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + 'file.txt', + 'other file.txt', + ])) + }) + + /** + * Regression test of https://help.nextcloud.com/t/unable-to-download-files-on-nextcloud-when-multiple-files-selected/221327/5 + */ + it('can download selected files with special characters', () => { + cy.createRandomUser().then((user) => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/1+1.txt') + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/some@other.txt') + cy.login(user) + cy.visit('/apps/files') + }) + + getRowForFile('some@other.txt') + .should('be.visible') + .findByRole('checkbox') + .check({ force: true }) + + getRowForFile('1+1.txt') + .should('be.visible') + .findByRole('checkbox') + .check({ force: true }) + + cy.get('[data-cy-files-list]').within(() => { + // see that two files are selected + cy.contains('2 selected').should('be.visible') + }) + + // click download + cy.get('[data-cy-files-list-selection-actions]') + .findByRole('button', { name: 'Actions' }) + .click() + cy.findByRole('menuitem', { name: 'Download (selected)' }) + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/download.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + '1+1.txt', + 'some@other.txt', + ])) + }) + + /** + * Regression test of https://help.nextcloud.com/t/unable-to-download-files-on-nextcloud-when-multiple-files-selected/221327/5 + */ + it('can download selected files with email uid', () => { + const name = `${randomString(5)}@${randomString(3)}` + const user: User = { userId: name, password: name, language: 'en' } + + cy.createUser(user).then(() => { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt') + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/other file.txt') + cy.login(user) + cy.visit('/apps/files') + }) + + getRowForFile('file.txt') + .should('be.visible') + .findByRole('checkbox') + .check({ force: true }) + + getRowForFile('other file.txt') + .should('be.visible') + .findByRole('checkbox') + .check({ force: true }) + + cy.get('[data-cy-files-list]').within(() => { + // see that two files are selected + cy.contains('2 selected').should('be.visible') + }) + + // click download + cy.get('[data-cy-files-list-selection-actions]') + .findByRole('button', { name: 'Actions' }) + .click() + cy.findByRole('menuitem', { name: 'Download (selected)' }) + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/download.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + 'file.txt', + 'other file.txt', + ])) + }) +}) diff --git a/cypress/e2e/files/files-filtering.cy.ts b/cypress/e2e/files/files-filtering.cy.ts new file mode 100644 index 00000000000..9499d9ff49c --- /dev/null +++ b/cypress/e2e/files/files-filtering.cy.ts @@ -0,0 +1,280 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { getRowForFile, navigateToFolder } from './FilesUtils' +import { FilesNavigationPage } from '../../pages/FilesNavigation' +import { FilesFilterPage } from '../../pages/FilesFilters' + +describe('files: Filter in files list', { testIsolation: true }, () => { + const appNavigation = new FilesNavigationPage() + const filesFilters = new FilesFilterPage() + let user: User + + beforeEach(() => cy.createRandomUser().then(($user) => { + user = $user + + cy.mkdir(user, '/folder') + cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') + cy.uploadContent(user, new Blob([]), 'text/csv', '/spreadsheet.csv') + cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/text.txt') + cy.login(user) + cy.visit('/apps/files') + })) + + it('filters current view by name', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + // Set up a search query + appNavigation.searchInput() + .type('folder') + + // See that only the folder is visible + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('not.exist') + getRowForFile('spreadsheet.csv').should('not.exist') + }) + + it('can reset name filter', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + // Set up a search query + appNavigation.searchInput() + .type('folder') + + // See that only the folder is visible + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('not.exist') + + // reset the filter + appNavigation.searchInput().should('have.value', 'folder') + appNavigation.searchClearButton().should('exist').click() + appNavigation.searchInput().should('have.value', '') + + // All are visible again + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + }) + + it('filters current view by type', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + getRowForFile('spreadsheet.csv').should('be.visible') + + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitemcheckbox', { name: 'Spreadsheets' }) + .should('be.visible') + .click() + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + + // See that only the spreadsheet is visible + getRowForFile('spreadsheet.csv').should('be.visible') + getRowForFile('file.txt').should('not.exist') + getRowForFile('folder').should('not.exist') + }) + + it('can reset filter by type', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitemcheckbox', { name: 'Spreadsheets' }) + .should('be.visible') + .click() + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + + // See folder is not visible + getRowForFile('folder').should('not.exist') + + // clear filter + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitem', { name: /clear filter/i }) + .should('be.visible') + .click() + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + + // See folder is visible again + getRowForFile('folder').should('be.visible') + }) + + it('can reset filter by clicking chip button', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitemcheckbox', { name: 'Spreadsheets' }) + .should('be.visible') + .click() + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + + // See folder is not visible + getRowForFile('folder').should('not.exist') + + // clear filter + filesFilters.removeFilter('Spreadsheets') + + // See folder is visible again + getRowForFile('folder').should('be.visible') + }) + + it('keeps name filter when changing the directory', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + // Set up a search query + appNavigation.searchInput() + .type('folder') + + // See that only the folder is visible + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('not.exist') + + // go to that folder + navigateToFolder('folder') + + // see that the folder is also filtered + getRowForFile('text.txt').should('not.exist') + }) + + it('keeps type filter when changing the directory', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitemcheckbox', { name: 'Folders' }) + .should('be.visible') + .click() + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .click() + + // See that only the folder is visible + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('not.exist') + + // see filter is active + filesFilters.activeFilters().contains(/Folder/).should('be.visible') + + // go to that folder + navigateToFolder('folder') + + // see filter is still active + filesFilters.activeFilters().contains(/Folder/).should('be.visible') + + // see that the folder is filtered + getRowForFile('text.txt').should('not.exist') + }) + + /** Regression test of https://github.com/nextcloud/server/issues/47251 */ + it('keeps filter state when changing the directory', () => { + // files are visible + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + // enable type filter for folders + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitemcheckbox', { name: 'Folders' }) + .should('be.visible') + .click() + // assert the button is checked + cy.findByRole('menuitemcheckbox', { name: 'Folders' }) + .should('have.attr', 'aria-checked', 'true') + // close the menu + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .click() + + // See the chips are active + filesFilters.activeFilters() + .should('have.length', 1) + .contains(/Folder/).should('be.visible') + + // See that folder is visible but file not + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('not.exist') + + // Change the directory + navigateToFolder('folder') + getRowForFile('folder').should('not.exist') + + // See that the chip is still active + filesFilters.activeFilters() + .should('have.length', 1) + .contains(/Folder/).should('be.visible') + // And also the button should be active + filesFilters.filterContainter() + .findByRole('button', { name: 'Type' }) + .should('be.visible') + .click() + cy.findByRole('menuitemcheckbox', { name: 'Folders' }) + .should('be.visible') + .and('have.attr', 'aria-checked', 'true') + }) + + it('resets filter when changing the view', () => { + // All are visible by default + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + // Set up a search query + appNavigation.searchInput() + .type('folder') + + // See that only the folder is visible + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('not.exist') + + // go to other view + appNavigation.views() + .findByRole('link', { name: /personal files/i }) + .click() + // wait for view changed + cy.url().should('match', /apps\/files\/personal/) + + // see that the folder is not filtered + getRowForFile('folder').should('be.visible') + getRowForFile('file.txt').should('be.visible') + + // see the filter bar is gone + appNavigation.searchInput().should('have.value', '') + }) +}) diff --git a/cypress/e2e/files/files-navigation.cy.ts b/cypress/e2e/files/files-navigation.cy.ts new file mode 100644 index 00000000000..4cc56990caf --- /dev/null +++ b/cypress/e2e/files/files-navigation.cy.ts @@ -0,0 +1,55 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { getRowForFile, navigateToFolder } from './FilesUtils.ts' + +describe('files: Navigate through folders and observe behavior', () => { + let user: User + + before(() => { + cy.createRandomUser().then(($user) => { + user = $user + cy.mkdir(user, '/foo') + cy.mkdir(user, '/foo/bar') + cy.mkdir(user, '/foo/bar/baz') + }) + }) + + it('Shows root folder and we can navigate to the last folder', () => { + cy.login(user) + cy.visit('/apps/files/') + + getRowForFile('foo').should('be.visible') + navigateToFolder('/foo/bar/baz') + + // Last folder is empty + cy.get('[data-cy-files-list-row-fileid]').should('not.exist') + }) + + it('Highlight the previous folder when navigating back', () => { + cy.go('back') + getRowForFile('baz').should('be.visible') + .invoke('attr', 'class').should('contain', 'active') + + cy.go('back') + getRowForFile('bar').should('be.visible') + .invoke('attr', 'class').should('contain', 'active') + + cy.go('back') + getRowForFile('foo').should('be.visible') + .invoke('attr', 'class').should('contain', 'active') + }) + + it('Can navigate forward again', () => { + cy.go('forward') + getRowForFile('bar').should('be.visible') + .invoke('attr', 'class').should('contain', 'active') + + cy.go('forward') + getRowForFile('baz').should('be.visible') + .invoke('attr', 'class').should('contain', 'active') + }) +}) diff --git a/cypress/e2e/files/files-renaming.cy.ts b/cypress/e2e/files/files-renaming.cy.ts new file mode 100644 index 00000000000..ac1edb1e104 --- /dev/null +++ b/cypress/e2e/files/files-renaming.cy.ts @@ -0,0 +1,285 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { calculateViewportHeight, createFolder, getRowForFile, haveValidity, renameFile, triggerActionForFile } from './FilesUtils' + +describe('files: Rename nodes', { testIsolation: true }, () => { + let user: User + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + + // remove welcome file + cy.rm(user, '/welcome.txt') + // create a file called "file.txt" + cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') + + // login and visit files app + cy.login(user) + }) + cy.visit('/apps/files') + }) + + it('can rename a file', () => { + // All are visible by default + getRowForFile('file.txt').should('be.visible') + + triggerActionForFile('file.txt', 'rename') + + getRowForFile('file.txt') + .findByRole('textbox', { name: 'Filename' }) + .should('be.visible') + .type('{selectAll}other.txt') + .should(haveValidity('')) + .type('{enter}') + + // See it is renamed + getRowForFile('other.txt').should('be.visible') + }) + + /** + * If this test gets flaky than we have a problem: + * It means that the selection is not reliable set to the basename + */ + it('only selects basename of file', () => { + // All are visible by default + getRowForFile('file.txt').should('be.visible') + + triggerActionForFile('file.txt', 'rename') + + getRowForFile('file.txt') + .findByRole('textbox', { name: 'Filename' }) + .should('be.visible') + .should((el) => { + const input = el.get(0) as HTMLInputElement + expect(input.selectionStart).to.equal(0) + expect(input.selectionEnd).to.equal('file'.length) + }) + }) + + it('show validation error on file rename', () => { + // All are visible by default + getRowForFile('file.txt').should('be.visible') + + triggerActionForFile('file.txt', 'rename') + + getRowForFile('file.txt') + .findByRole('textbox', { name: 'Filename' }) + .should('be.visible') + .type('{selectAll}.htaccess') + // See validity + .should(haveValidity(/reserved name/i)) + }) + + it('shows accessible loading information', () => { + const { resolve, promise } = Promise.withResolvers<void>() + + getRowForFile('file.txt').should('be.visible') + + // intercept the rename (MOVE) + // the callback will wait until the promise resolve (so we have time to check the loading state) + cy.intercept( + 'MOVE', + /\/remote.php\/dav\/files\//, + (request) => { + // we need to wait in the onResponse handler as the intercept handler times out otherwise + request.on('response', async () => { await promise }) + }, + ).as('moveFile') + + // Start the renaming + triggerActionForFile('file.txt', 'rename') + getRowForFile('file.txt') + .findByRole('textbox', { name: 'Filename' }) + .should('be.visible') + .type('{selectAll}new-name.txt{enter}') + + // Loading state is visible + getRowForFile('new-name.txt') + .findByRole('img', { name: 'File is loading' }) + .should('be.visible') + // checkbox is not visible + getRowForFile('new-name.txt') + .findByRole('checkbox', { name: /^Toggle selection/ }) + .should('not.exist') + + cy.log('Resolve promise to preoceed with MOVE request') + .then(() => resolve()) + + // Ensure the request is done (file renamed) + cy.wait('@moveFile') + + // checkbox visible again + getRowForFile('new-name.txt') + .findByRole('checkbox', { name: /^Toggle selection/ }) + .should('exist') + // see the loading state is gone + getRowForFile('new-name.txt') + .findByRole('img', { name: 'File is loading' }) + .should('not.exist') + }) + + it('cancel renaming on esc press', () => { + // All are visible by default + getRowForFile('file.txt').should('be.visible') + + triggerActionForFile('file.txt', 'rename') + + getRowForFile('file.txt') + .findByRole('textbox', { name: 'Filename' }) + .should('be.visible') + .type('{selectAll}other.txt') + .should(haveValidity('')) + .type('{esc}') + + // See it is not renamed + getRowForFile('other.txt').should('not.exist') + getRowForFile('file.txt') + .should('be.visible') + .find('input[type="text"]') + .should('not.exist') + }) + + it('cancel on enter if no new name is entered', () => { + // All are visible by default + getRowForFile('file.txt').should('be.visible') + + triggerActionForFile('file.txt', 'rename') + + getRowForFile('file.txt') + .findByRole('textbox', { name: 'Filename' }) + .should('be.visible') + .type('{enter}') + + // See it is not renamed + getRowForFile('file.txt') + .should('be.visible') + .find('input[type="text"]') + .should('not.exist') + }) + + /** + * This is a regression test of: https://github.com/nextcloud/server/issues/47438 + * The issue was that the renaming state was not reset when the new name moved the file out of the view of the current files list + * due to virtual scrolling the renaming state was not changed then by the UI events (as the component was taken out of DOM before any event handling). + */ + it('correctly resets renaming state', () => { + // Create 19 additional files + for (let i = 1; i <= 19; i++) { + cy.uploadContent(user, new Blob([]), 'text/plain', `/file${i}.txt`) + } + + // Calculate and setup a viewport where only the first 4 files are visible, causing 6 rows to be rendered + cy.viewport(768, 500) + cy.login(user) + calculateViewportHeight(4) + .then((height) => cy.viewport(768, height)) + + cy.visit('/apps/files') + + getRowForFile('file.txt') + .should('be.visible') + // Z so it is shown last + renameFile('file.txt', 'zzz.txt') + // not visible any longer + getRowForFile('zzz.txt') + .should('not.exist') + // scroll file list to bottom + cy.get('[data-cy-files-list]') + .scrollTo('bottom') + cy.screenshot() + // The file is no longer in rename state + getRowForFile('zzz.txt') + .should('be.visible') + .findByRole('textbox', { name: 'Filename' }) + .should('not.exist') + }) + + it('shows warning on extension change - select new extension', () => { + getRowForFile('file.txt').should('be.visible') + + triggerActionForFile('file.txt', 'rename') + getRowForFile('file.txt') + .findByRole('textbox', { name: 'Filename' }) + .should('be.visible') + .type('{selectAll}file.md') + .type('{enter}') + + // See warning dialog + cy.findByRole('dialog', { name: 'Change file extension' }) + .should('be.visible') + .findByRole('button', { name: 'Use .md' }) + .click() + + // See it is renamed + getRowForFile('file.md').should('be.visible') + }) + + it('shows warning on extension change - select old extension', () => { + getRowForFile('file.txt').should('be.visible') + + triggerActionForFile('file.txt', 'rename') + getRowForFile('file.txt') + .findByRole('textbox', { name: 'Filename' }) + .should('be.visible') + .type('{selectAll}document.md') + .type('{enter}') + + // See warning dialog + cy.findByRole('dialog', { name: 'Change file extension' }) + .should('be.visible') + .findByRole('button', { name: 'Keep .txt' }) + .click() + + // See it is renamed + getRowForFile('document.txt').should('be.visible') + }) + + it('shows warning on extension removal', () => { + getRowForFile('file.txt').should('be.visible') + + triggerActionForFile('file.txt', 'rename') + getRowForFile('file.txt') + .findByRole('textbox', { name: 'Filename' }) + .should('be.visible') + .type('{selectAll}file') + .type('{enter}') + + cy.findByRole('dialog', { name: 'Change file extension' }) + .should('be.visible') + .findByRole('button', { name: 'Keep .txt' }) + .should('be.visible') + cy.findByRole('dialog', { name: 'Change file extension' }) + .findByRole('button', { name: 'Remove extension' }) + .should('be.visible') + .click() + + // See it is renamed + getRowForFile('file').should('be.visible') + getRowForFile('file.txt').should('not.exist') + }) + + it('does not show warning on folder renaming with a dot', () => { + createFolder('folder.2024') + + getRowForFile('folder.2024').should('be.visible') + + triggerActionForFile('folder.2024', 'rename') + getRowForFile('folder.2024') + .findByRole('textbox', { name: 'Folder name' }) + .should('be.visible') + .type('{selectAll}folder.2025') + .should(haveValidity('')) + .type('{enter}') + + // See warning dialog + cy.get('[role=dialog]').should('not.exist') + + // See it is not renamed + getRowForFile('folder.2025').should('be.visible') + }) +}) diff --git a/cypress/e2e/files/files-searching.cy.ts b/cypress/e2e/files/files-searching.cy.ts deleted file mode 100644 index 293d267bbb0..00000000000 --- a/cypress/e2e/files/files-searching.cy.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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 type { User } from '@nextcloud/cypress' -import { getRowForFile, navigateToFolder } from './FilesUtils' -import { UnifiedSearchFilter, getUnifiedSearchFilter, getUnifiedSearchInput, getUnifiedSearchModal, openUnifiedSearch } from '../core-utils.ts' - -describe('files: Search and filter in files list', { testIsolation: true }, () => { - let user: User - - beforeEach(() => cy.createRandomUser().then(($user) => { - user = $user - - cy.mkdir(user, '/a folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/b file') - cy.uploadContent(user, new Blob([]), 'text/plain', '/a folder/c file') - cy.login(user) - cy.visit('/apps/files') - })) - - it('filters current view', () => { - // All are visible by default - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('be.visible') - - // Set up a search query - openUnifiedSearch() - getUnifiedSearchInput().type('a folder') - getUnifiedSearchFilter(UnifiedSearchFilter.FilterCurrentView).click({ force: true }) - // Wait for modal to close - getUnifiedSearchModal().should('not.be.visible') - - // See that only the folder is visible - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('not.exist') - }) - - it('resets filter when changeing the directory', () => { - // All are visible by default - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('be.visible') - - // Set up a search query - openUnifiedSearch() - getUnifiedSearchInput().type('a folder') - getUnifiedSearchFilter(UnifiedSearchFilter.FilterCurrentView).click({ force: true }) - // Wait for modal to close - getUnifiedSearchModal().should('not.be.visible') - - // See that only the folder is visible - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('not.exist') - - // go to that folder - navigateToFolder('a folder') - - // see that the folder is not filtered - getRowForFile('c file').should('be.visible') - }) - - it('resets filter when changeing the view', () => { - // All are visible by default - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('be.visible') - - // Set up a search query - openUnifiedSearch() - getUnifiedSearchInput().type('a folder') - getUnifiedSearchFilter(UnifiedSearchFilter.FilterCurrentView).click({ force: true }) - // Wait for modal to close - getUnifiedSearchModal().should('not.be.visible') - - // See that only the folder is visible - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('not.exist') - - // go to other view - cy.get('[data-cy-files-navigation-item="personal"] a').click({ force: true }) - // wait for view changed - cy.url().should('match', /apps\/files\/personal/) - - // see that the folder is not filtered - getRowForFile('a folder').should('be.visible') - getRowForFile('b file').should('be.visible') - }) -}) diff --git a/cypress/e2e/files/files-selection.cy.ts b/cypress/e2e/files/files-selection.cy.ts new file mode 100644 index 00000000000..c50543a8c7c --- /dev/null +++ b/cypress/e2e/files/files-selection.cy.ts @@ -0,0 +1,77 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { deselectAllFiles, selectAllFiles, selectRowForFile } from './FilesUtils' + +const files = { + 'image.jpg': 'image/jpeg', + 'document.pdf': 'application/pdf', + 'archive.zip': 'application/zip', + 'audio.mp3': 'audio/mpeg', + 'video.mp4': 'video/mp4', + 'readme.md': 'text/markdown', + 'welcome.txt': 'text/plain', +} +const filesCount = Object.keys(files).length + +describe('files: Select all files', { testIsolation: true }, () => { + let user: User + + before(() => { + cy.createRandomUser().then(($user) => { + user = $user + Object.keys(files).forEach((file) => { + cy.uploadContent(user, new Blob(), files[file], '/' + file) + }) + }) + }) + + beforeEach(() => { + cy.login(user) + cy.visit('/apps/files') + }) + + it('Can select and unselect all files', () => { + cy.get('[data-cy-files-list-row-fileid]').should('have.length', filesCount) + cy.get('[data-cy-files-list-row-checkbox]').should('have.length', filesCount) + + selectAllFiles() + + cy.get('.files-list__selected').should('contain.text', '7 selected') + cy.get('[data-cy-files-list-row-checkbox]').findByRole('checkbox').should('be.checked') + + deselectAllFiles() + + cy.get('.files-list__selected').should('not.exist') + cy.get('[data-cy-files-list-row-checkbox]').findByRole('checkbox').should('not.be.checked') + }) + + it('Can select some files randomly', () => { + const randomFiles = Object.keys(files).reduce((acc, file) => { + if (Math.random() > 0.1) { + acc.push(file) + } + return acc + }, [] as string[]) + + randomFiles.forEach(name => selectRowForFile(name)) + + cy.get('.files-list__selected').should('contain.text', `${randomFiles.length} selected`) + cy.get('[data-cy-files-list-row-checkbox] input[type="checkbox"]:checked').should('have.length', randomFiles.length) + }) + + it('Can select range of files with shift key', () => { + cy.get('[data-cy-files-list-row-checkbox]').should('have.length', filesCount) + selectRowForFile('audio.mp3') + cy.window().trigger('keydown', { key: 'ShiftLeft', shiftKey: true }) + selectRowForFile('readme.md') + cy.window().trigger('keyup', { key: 'ShiftLeft', shiftKey: true }) + + cy.get('.files-list__selected').should('contain.text', '4 selected') + cy.get('[data-cy-files-list-row-checkbox] input[type="checkbox"]:checked').should('have.length', 4) + + }) +}) diff --git a/cypress/e2e/files/files-settings.cy.ts b/cypress/e2e/files/files-settings.cy.ts index 497f7c8782e..b363e630b44 100644 --- a/cypress/e2e/files/files-settings.cy.ts +++ b/cypress/e2e/files/files-settings.cy.ts @@ -1,39 +1,66 @@ /** - * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { User } from '@nextcloud/cypress' -import { getRowForFile } from './FilesUtils' -const showHiddenFiles = () => { - // Open the files settings - cy.get('[data-cy-files-navigation-settings-button] a').click({ force: true }) - // Toggle the hidden files setting - cy.get('[data-cy-files-settings-setting="show_hidden"]').within(() => { - cy.get('input').should('not.be.checked') - cy.get('input').check({ force: true }) +import { getRowForFile } from './FilesUtils.ts' + +describe('files: Set default view', { testIsolation: true }, () => { + beforeEach(() => { + cy.createRandomUser().then(($user) => { + cy.login($user) + }) }) - // Close the dialog - cy.get('[data-cy-files-navigation-settings] button[aria-label="Close"]').click() -} + + it('Defaults to the "files" view', () => { + cy.visit('/apps/files') + + // See URL and current view + cy.url().should('match', /\/apps\/files\/files/) + cy.get('[data-cy-files-content-breadcrumbs]') + .findByRole('button', { + name: 'All files', + description: 'Reload current directory', + }) + + // See the option is also selected + // Open the files settings + cy.findByRole('link', { name: 'Files settings' }).click({ force: true }) + // Toggle the setting + cy.findByRole('dialog', { name: 'Files settings' }) + .should('be.visible') + .within(() => { + cy.findByRole('group', { name: 'Default view' }) + .findByRole('radio', { name: 'All files' }) + .should('be.checked') + }) + }) + + it('Can set it to personal files', () => { + cy.visit('/apps/files') + + // Open the files settings + cy.findByRole('link', { name: 'Files settings' }).click({ force: true }) + // Toggle the setting + cy.findByRole('dialog', { name: 'Files settings' }) + .should('be.visible') + .within(() => { + cy.findByRole('group', { name: 'Default view' }) + .findByRole('radio', { name: 'Personal files' }) + .check({ force: true }) + }) + + cy.visit('/apps/files') + cy.url().should('match', /\/apps\/files\/personal/) + cy.get('[data-cy-files-content-breadcrumbs]') + .findByRole('button', { + name: 'Personal files', + description: 'Reload current directory', + }) + }) +}) describe('files: Hide or show hidden files', { testIsolation: true }, () => { let user: User @@ -114,3 +141,18 @@ describe('files: Hide or show hidden files', { testIsolation: true }, () => { }) }) }) + +/** + * Helper to toggle the hidden files settings + */ +function showHiddenFiles() { + // Open the files settings + cy.get('[data-cy-files-navigation-settings-button] a').click({ force: true }) + // Toggle the hidden files setting + cy.get('[data-cy-files-settings-setting="show_hidden"]').within(() => { + cy.get('input').should('not.be.checked') + cy.get('input').check({ force: true }) + }) + // Close the dialog + cy.get('[data-cy-files-navigation-settings] button[aria-label="Close"]').click() +} diff --git a/cypress/e2e/files/files-sidebar.cy.ts b/cypress/e2e/files/files-sidebar.cy.ts new file mode 100644 index 00000000000..f5c4205c462 --- /dev/null +++ b/cypress/e2e/files/files-sidebar.cy.ts @@ -0,0 +1,126 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { getRowForFile, navigateToFolder, triggerActionForFile } from './FilesUtils' +import { assertNotExistOrNotVisible } from '../settings/usersUtils' + +describe('Files: Sidebar', { testIsolation: true }, () => { + let user: User + let fileId: number = 0 + + beforeEach(() => cy.createRandomUser().then(($user) => { + user = $user + + cy.mkdir(user, '/folder') + cy.uploadContent(user, new Blob([]), 'text/plain', '/file').then((response) => { + fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') + }) + cy.login(user) + })) + + it('opens the sidebar', () => { + cy.visit('/apps/files') + getRowForFile('file').should('be.visible') + + triggerActionForFile('file', 'details') + + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('heading', { name: 'file' }) + .should('be.visible') + }) + + it('changes the current fileid', () => { + cy.visit('/apps/files') + getRowForFile('file').should('be.visible') + + triggerActionForFile('file', 'details') + + cy.get('[data-cy-sidebar]').should('be.visible') + cy.url().should('contain', `apps/files/files/${fileId}`) + }) + + it('changes the sidebar content on other file', () => { + cy.visit('/apps/files') + getRowForFile('file').should('be.visible') + + triggerActionForFile('file', 'details') + + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('heading', { name: 'file' }) + .should('be.visible') + + triggerActionForFile('folder', 'details') + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('heading', { name: 'folder' }) + .should('be.visible') + }) + + it('closes the sidebar on navigation', () => { + cy.visit('/apps/files') + + getRowForFile('file').should('be.visible') + getRowForFile('folder').should('be.visible') + + // open the sidebar + triggerActionForFile('file', 'details') + // validate it is open + cy.get('[data-cy-sidebar]') + .should('be.visible') + + // if we navigate to the folder + navigateToFolder('folder') + // the sidebar should not be visible anymore + cy.get('[data-cy-sidebar]') + .should(assertNotExistOrNotVisible) + }) + + it('closes the sidebar on delete', () => { + cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/file`).as('deleteFile') + // visit the files app + cy.visit('/apps/files') + getRowForFile('file').should('be.visible') + // open the sidebar + triggerActionForFile('file', 'details') + // validate it is open + cy.get('[data-cy-sidebar]').should('be.visible') + // delete the file + triggerActionForFile('file', 'delete') + cy.wait('@deleteFile', { timeout: 10000 }) + // see the sidebar is closed + cy.get('[data-cy-sidebar]') + .should(assertNotExistOrNotVisible) + }) + + it('changes the fileid on delete', () => { + cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/folder/other`).as('deleteFile') + + cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/other').then((response) => { + const otherFileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') + cy.login(user) + cy.visit('/apps/files') + + getRowForFile('folder').should('be.visible') + navigateToFolder('folder') + getRowForFile('other').should('be.visible') + + // open the sidebar + triggerActionForFile('other', 'details') + // validate it is open + cy.get('[data-cy-sidebar]').should('be.visible') + cy.url().should('contain', `apps/files/files/${otherFileId}`) + + triggerActionForFile('other', 'delete') + cy.wait('@deleteFile') + + cy.get('[data-cy-sidebar]').should('not.exist') + // Ensure the URL is changed + cy.url().should('not.contain', `apps/files/files/${otherFileId}`) + }) + }) +}) diff --git a/cypress/e2e/files/files_sorting.cy.ts b/cypress/e2e/files/files-sorting.cy.ts index 3e46d868c1e..9e726bf96e1 100644 --- a/cypress/e2e/files/files_sorting.cy.ts +++ b/cypress/e2e/files/files-sorting.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2022 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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ describe('Files: Sorting the file list', { testIsolation: true }, () => { let currentUser @@ -58,6 +41,31 @@ describe('Files: Sorting the file list', { testIsolation: true }, () => { }) }) + /** + * Regression test of https://github.com/nextcloud/server/issues/45829 + */ + it('Filesnames with numbers are sorted by name ascending by default', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/name.txt') + .uploadContent(currentUser, new Blob(), 'text/plain', '/name_03.txt') + .uploadContent(currentUser, new Blob(), 'text/plain', '/name_02.txt') + .uploadContent(currentUser, new Blob(), 'text/plain', '/name_01.txt') + cy.login(currentUser) + cy.visit('/apps/files') + + cy.get('[data-cy-files-list-row]').each(($row, index) => { + switch (index) { + case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('name.txt') + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_01.txt') + break + case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_02.txt') + break + case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_03.txt') + break + } + }) + }) + it('Can sort by size', () => { cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 tiny.txt') .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z big.txt') @@ -158,7 +166,6 @@ describe('Files: Sorting the file list', { testIsolation: true }, () => { cy.visit('/apps/files') cy.log('By name - ascending') - cy.get('th').contains('button', 'Name').click() cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'ascending') cy.get('[data-cy-files-list-row]').each(($row, index) => { @@ -259,4 +266,65 @@ describe('Files: Sorting the file list', { testIsolation: true }, () => { } }) }) + + it('Sorting works after switching view twice', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 tiny.txt') + .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z big.txt') + .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a medium.txt') + .mkdir(currentUser, '/folder') + cy.login(currentUser) + cy.visit('/apps/files') + + // click sort button twice + cy.get('th').contains('button', 'Size').click() + cy.get('th').contains('button', 'Size').click() + + // switch to personal and click sort button twice again + cy.get('[data-cy-files-navigation-item="personal"]').click() + cy.get('th').contains('button', 'Size').click() + cy.get('th').contains('button', 'Size').click() + + // switch back to files view and do actual assertions + cy.get('[data-cy-files-navigation-item="files"]').click() + + // click sort button + cy.get('th').contains('button', 'Size').click() + // sorting is set + cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending') + // Files are sorted + cy.get('[data-cy-files-list-row]').each(($row, index) => { + switch (index) { + case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder') + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt') + break + case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') + break + case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt') + break + case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt') + break + } + }) + + // click sort button + cy.get('th').contains('button', 'Size').click() + // sorting is set + cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending') + // Files are sorted + cy.get('[data-cy-files-list-row]').each(($row, index) => { + switch (index) { + case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder') + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt') + break + case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt') + break + case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') + break + case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt') + break + } + }) + }) }) diff --git a/cypress/e2e/files/files-xml-regression.cy.ts b/cypress/e2e/files/files-xml-regression.cy.ts index 5e26418d442..a961b78e2f4 100644 --- a/cypress/e2e/files/files-xml-regression.cy.ts +++ b/cypress/e2e/files/files-xml-regression.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2024 Ferdinand Thiessen - * - * @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: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { getRowForFile, triggerActionForFile } from './FilesUtils.ts' @@ -57,7 +40,7 @@ describe('Files: Can handle XML entities in file names', { testIsolation: false triggerActionForFile('&.txt', 'delete') cy.wait('@deleteFile') - cy.contains('.toast-success', /Delete .* successfull/) + cy.contains('.toast-success', /Delete .* done/) .should('be.visible') getRowForFile('&.txt').should('not.exist') diff --git a/cypress/e2e/files/files.cy.ts b/cypress/e2e/files/files.cy.ts index 33261be417e..efae1116d2d 100644 --- a/cypress/e2e/files/files.cy.ts +++ b/cypress/e2e/files/files.cy.ts @@ -1,33 +1,58 @@ +import type { User } from "@nextcloud/cypress" + /** - * @copyright Copyright (c) 2022 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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ describe('Files', { testIsolation: true }, () => { + let currentUser: User + beforeEach(() => { cy.createRandomUser().then((user) => { - cy.login(user) + currentUser = user }) }) it('Login with a user and open the files app', () => { + cy.login(currentUser) cy.visit('/apps/files') cy.get('[data-cy-files-list] [data-cy-files-list-row-name="welcome.txt"]').should('be.visible') }) + + it('Opens a valid file shows it as active', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt').then((response) => { + const fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') + + cy.login(currentUser) + cy.visit('/apps/files/files/' + fileId) + + cy.get(`[data-cy-files-list-row-fileid=${fileId}]`) + .should('be.visible') + cy.get(`[data-cy-files-list-row-fileid=${fileId}]`) + .invoke('attr', 'data-cy-files-list-row-name').should('eq', 'original.txt') + cy.get(`[data-cy-files-list-row-fileid=${fileId}]`) + .invoke('attr', 'class').should('contain', 'active') + cy.contains('The file could not be found').should('not.exist') + }) + }) + + it('Opens a valid folder shows its content', () => { + cy.mkdir(currentUser, '/folder').then(() => { + cy.login(currentUser) + cy.visit('/apps/files/files?dir=/folder') + + cy.get('[data-cy-files-content-breadcrumbs]').contains('folder').should('be.visible') + cy.contains('The file could not be found').should('not.exist') + }) + }) + + it('Opens an unknown file show an error', () => { + cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind') + cy.login(currentUser) + cy.visit('/apps/files/files/123456') + + cy.wait('@propfind') + // The toast should be visible + cy.contains('The file could not be found', { timeout: 5000 }).should('be.visible') + }) }) diff --git a/cypress/e2e/files/live_photos.cy.ts b/cypress/e2e/files/live_photos.cy.ts index aa2224c787b..8eb4efaaec0 100644 --- a/cypress/e2e/files/live_photos.cy.ts +++ b/cypress/e2e/files/live_photos.cy.ts @@ -1,95 +1,37 @@ /** - * @copyright Copyright (c) 2024 Louis Chmn <louis@chmn.me> - * - * @author Louis Chmn <louis@chmn.me> - * - * @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: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { User } from '@nextcloud/cypress' -import { clickOnBreadcrumbs, closeSidebar, copyFile, getRowForFile, getRowForFileId, renameFile, triggerActionForFile, triggerInlineActionForFileId } from './FilesUtils' - -/** - * - * @param user - * @param fileName - * @param domain - * @param requesttoken - * @param metadata - */ -function setMetadata(user: User, fileName: string, domain: string, requesttoken: string, metadata: object) { - cy.request({ - method: 'PROPPATCH', - url: `http://${domain}/remote.php/dav/files/${user.userId}/${fileName}`, - auth: { user: user.userId, pass: user.password }, - headers: { - requesttoken, - }, - body: `<?xml version="1.0"?> - <d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns"> - <d:set> - <d:prop> - ${Object.entries(metadata).map(([key, value]) => `<${key}>${value}</${key}>`).join('\n')} - </d:prop> - </d:set> - </d:propertyupdate>`, - }) -} +import { + clickOnBreadcrumbs, + copyFile, + createFolder, + getRowForFile, + getRowForFileId, + moveFile, + navigateToFolder, + renameFile, + triggerActionForFile, + triggerInlineActionForFileId, +} from './FilesUtils' +import { setShowHiddenFiles, setupLivePhotos } from './LivePhotosUtils' describe('Files: Live photos', { testIsolation: true }, () => { - let currentUser: User + let user: User let randomFileName: string let jpgFileId: number let movFileId: number - let hostname: string - let requesttoken: string - - before(() => { - cy.createRandomUser().then((user) => { - currentUser = user - cy.login(currentUser) - cy.visit('/apps/files') - }) - - cy.url().then(url => { hostname = new URL(url).hostname }) - }) beforeEach(() => { - randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) - - cy.uploadContent(currentUser, new Blob(['jpg file'], { type: 'image/jpg' }), 'image/jpg', `/${randomFileName}.jpg`) - .then(response => { jpgFileId = parseInt(response.headers['oc-fileid']) }) - cy.uploadContent(currentUser, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/${randomFileName}.mov`) - .then(response => { movFileId = parseInt(response.headers['oc-fileid']) }) - - cy.login(currentUser) - cy.visit('/apps/files') - - cy.get('head').invoke('attr', 'data-requesttoken').then(_requesttoken => { requesttoken = _requesttoken as string }) - - cy.then(() => { - setMetadata(currentUser, `${randomFileName}.jpg`, hostname, requesttoken, { 'nc:metadata-files-live-photo': movFileId }) - setMetadata(currentUser, `${randomFileName}.mov`, hostname, requesttoken, { 'nc:metadata-files-live-photo': jpgFileId }) - }) - - cy.then(() => { - cy.visit(`/apps/files/files/${jpgFileId}`) // Refresh and scroll to the .jpg file. - closeSidebar() - }) + setupLivePhotos() + .then((setupInfo) => { + user = setupInfo.user + randomFileName = setupInfo.fileName + jpgFileId = setupInfo.jpgFileId + movFileId = setupInfo.movFileId + }) }) it('Only renders the .jpg file', () => { @@ -98,12 +40,8 @@ describe('Files: Live photos', { testIsolation: true }, () => { }) context("'Show hidden files' is enabled", () => { - before(() => { - cy.login(currentUser) - cy.visit('/apps/files') - cy.get('[data-cy-files-navigation-settings-button]').click() - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('[data-cy-files-settings-setting="show_hidden"] input').check({ force: true }) + beforeEach(() => { + setShowHiddenFiles(true) }) it("Shows both files when 'Show hidden files' is enabled", () => { @@ -130,6 +68,35 @@ describe('Files: Live photos', { testIsolation: true }, () => { getRowForFile(`${randomFileName} (copy).mov`).should('have.length', 1) }) + it('Keeps live photo link when copying folder', () => { + createFolder('folder') + moveFile(`${randomFileName}.jpg`, 'folder') + copyFile('folder', '.') + navigateToFolder('folder (copy)') + + getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) + getRowForFile(`${randomFileName}.mov`).should('have.length', 1) + + setShowHiddenFiles(false) + + getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) + getRowForFile(`${randomFileName}.mov`).should('have.length', 0) + }) + + it('Block copying live photo in a folder containing a mov file with the same name', () => { + createFolder('folder') + cy.uploadContent(user, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/folder/${randomFileName}.mov`) + cy.login(user) + cy.visit('/apps/files') + copyFile(`${randomFileName}.jpg`, 'folder') + navigateToFolder('folder') + + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1) + getRowForFile(`${randomFileName}.mov`).should('have.length', 1) + getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) + getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 0) + }) + it('Moves files when moving the .jpg', () => { renameFile(`${randomFileName}.jpg`, `${randomFileName}_moved.jpg`) clickOnBreadcrumbs('All files') diff --git a/cypress/e2e/files/new-menu.cy.ts b/cypress/e2e/files/new-menu.cy.ts new file mode 100644 index 00000000000..dfe586fa073 --- /dev/null +++ b/cypress/e2e/files/new-menu.cy.ts @@ -0,0 +1,123 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createFolder, getRowForFile, haveValidity, navigateToFolder } from './FilesUtils' + +describe('"New"-menu', { testIsolation: true }, () => { + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + cy.login($user) + cy.visit('/apps/files') + }) + }) + + it('Create new folder', () => { + // Click the "new" button + cy.get('[data-cy-upload-picker]') + .findByRole('button', { name: 'New' }) + .should('be.visible') + .click() + // Click the "new folder" menu entry + cy.findByRole('menuitem', { name: 'New folder' }) + .should('be.visible') + .click() + // Create a folder + cy.intercept('MKCOL', '**/remote.php/dav/files/**').as('mkdir') + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .type('A new folder{enter}') + cy.wait('@mkdir') + // See the folder is visible + getRowForFile('A new folder') + .should('be.visible') + }) + + it('Does not allow creating forbidden folder names', () => { + // Click the "new" button + cy.get('[data-cy-upload-picker]') + .findByRole('button', { name: 'New' }) + .should('be.visible') + .click() + // Click the "new folder" menu entry + cy.findByRole('menuitem', { name: 'New folder' }) + .should('be.visible') + .click() + // enter folder name + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .type('.htaccess') + // See that input has invalid state set + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .should(haveValidity(/reserved name/i)) + // See that it can not create + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('button', { name: 'Create' }) + .should('be.disabled') + }) + + it('Does not allow creating folders with already existing names', () => { + createFolder('already exists') + // Click the "new" button + cy.get('[data-cy-upload-picker]') + .findByRole('button', { name: 'New' }) + .should('be.visible') + .click() + // Click the "new folder" menu entry + cy.findByRole('menuitem', { name: 'New folder' }) + .should('be.visible') + .click() + // enter folder name + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .type('already exists') + // See that input has invalid state set + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .should(haveValidity(/already in use/i)) + // See that it can not create + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('button', { name: 'Create' }) + .should('be.disabled') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/47530 + */ + it('Create same folder in child folder', () => { + // setup other folders + createFolder('folder') + createFolder('other folder') + navigateToFolder('folder') + + // Click the "new" button + cy.get('[data-cy-upload-picker]') + .findByRole('button', { name: 'New' }) + .should('be.visible') + .click() + // Click the "new folder" menu entry + cy.findByRole('menuitem', { name: 'New folder' }) + .should('be.visible') + .click() + // enter folder name + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .type('other folder') + // See that creating is allowed + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('textbox', { name: 'Folder name' }) + .should(haveValidity('')) + // can create + cy.intercept('MKCOL', '**/remote.php/dav/files/**').as('mkdir') + cy.findByRole('dialog', { name: /create new folder/i }) + .findByRole('button', { name: 'Create' }) + .click() + cy.wait('@mkdir') + // see it is created + getRowForFile('other folder') + .should('be.visible') + }) +}) diff --git a/cypress/e2e/files/recent-view.cy.ts b/cypress/e2e/files/recent-view.cy.ts new file mode 100644 index 00000000000..64eeca9a085 --- /dev/null +++ b/cypress/e2e/files/recent-view.cy.ts @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { getRowForFile, triggerActionForFile } from './FilesUtils' + +describe('files: Recent view', { testIsolation: true }, () => { + let user: User + + beforeEach(() => cy.createRandomUser().then(($user) => { + user = $user + + cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') + cy.login(user) + })) + + it('see the recently created file in the recent view', () => { + cy.visit('/apps/files/recent') + // All are visible by default + getRowForFile('file.txt').should('be.visible') + }) + + /** + * Regression test: There was a bug that the files were correctly loaded but with invalid source + * so the delete action failed. + */ + it('can delete a file in the recent view', () => { + cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile') + + cy.visit('/apps/files/recent') + // See the row + getRowForFile('file.txt').should('be.visible') + // delete the file + triggerActionForFile('file.txt', 'delete') + cy.wait('@deleteFile') + // See it is not visible anymore + getRowForFile('file.txt').should('not.exist') + // also not existing in default view after reload + cy.visit('/apps/files') + getRowForFile('file.txt').should('not.exist') + }) +}) diff --git a/cypress/e2e/files/router-query.cy.ts b/cypress/e2e/files/router-query.cy.ts new file mode 100644 index 00000000000..9c6564c8ecf --- /dev/null +++ b/cypress/e2e/files/router-query.cy.ts @@ -0,0 +1,180 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { join } from 'path' +import { getRowForFileId } from './FilesUtils.ts' + +/** + * Check that the sidebar is opened for a specific file + * @param name The name of the file + */ +function sidebarIsOpen(name: string): void { + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('heading', { name }) + .should('be.visible') +} + +/** + * Skip a test without viewer installed + */ +function skipIfViewerDisabled(this: Mocha.Context): void { + cy.runOccCommand('app:list --enabled --output json') + .then((exec) => exec.stdout) + .then((output) => JSON.parse(output)) + .then((obj) => 'viewer' in obj.enabled) + .then((enabled) => { + if (!enabled) { + this.skip() + } + }) +} + +/** + * Check a file was not downloaded + * @param filename The expected filename + */ +function fileNotDownloaded(filename: string): void { + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(join(downloadsFolder, filename)).should('not.exist') +} + +describe('Check router query flags:', function() { + let user: User + let imageId: number + let archiveId: number + let folderId: number + + before(() => { + cy.createRandomUser().then(($user) => { + user = $user + cy.uploadFile(user, 'image.jpg') + .then((response) => { imageId = Number.parseInt(response.headers['oc-fileid']) }) + cy.mkdir(user, '/folder') + .then((response) => { folderId = Number.parseInt(response.headers['oc-fileid']) }) + cy.uploadContent(user, new Blob([]), 'application/zstd', '/archive.zst') + .then((response) => { archiveId = Number.parseInt(response.headers['oc-fileid']) }) + cy.login(user) + }) + }) + + describe('"opendetails"', () => { + it('open details for known file type', () => { + cy.visit(`/apps/files/files/${imageId}?opendetails`) + + // see sidebar + sidebarIsOpen('image.jpg') + + // but no viewer + cy.findByRole('dialog', { name: 'image.jpg' }) + .should('not.exist') + + // and no download + fileNotDownloaded('image.jpg') + }) + + it('open details for unknown file type', () => { + cy.visit(`/apps/files/files/${archiveId}?opendetails`) + + // see sidebar + sidebarIsOpen('archive.zst') + + // but no viewer + cy.findByRole('dialog', { name: 'archive.zst' }) + .should('not.exist') + + // and no download + fileNotDownloaded('archive.zst') + }) + + it('open details for folder', () => { + cy.visit(`/apps/files/files/${folderId}?opendetails`) + + // see sidebar + sidebarIsOpen('folder') + + // but no viewer + cy.findByRole('dialog', { name: 'folder' }) + .should('not.exist') + + // and no download + fileNotDownloaded('folder') + }) + }) + + describe('"openfile"', function() { + /** Check the viewer is open and shows the image */ + function viewerShowsImage(): void { + cy.findByRole('dialog', { name: 'image.jpg' }) + .should('be.visible') + .find(`img[src*="fileId=${imageId}"]`) + .should('be.visible') + } + + it('opens files with default action', function() { + skipIfViewerDisabled.call(this) + + cy.visit(`/apps/files/files/${imageId}?openfile`) + viewerShowsImage() + }) + + it('opens files with default action using explicit query state', function() { + skipIfViewerDisabled.call(this) + + cy.visit(`/apps/files/files/${imageId}?openfile=true`) + viewerShowsImage() + }) + + it('does not open files with default action when using explicitly query value `false`', function() { + skipIfViewerDisabled.call(this) + + cy.visit(`/apps/files/files/${imageId}?openfile=false`) + getRowForFileId(imageId) + .should('be.visible') + .and('have.class', 'files-list__row--active') + + cy.findByRole('dialog', { name: 'image.jpg' }) + .should('not.exist') + }) + + it('does not open folders but shows details', () => { + cy.visit(`/apps/files/files/${folderId}?openfile`) + + // See the URL was replaced + cy.url() + .should('match', /[?&]opendetails(&|=|$)/) + .and('not.match', /openfile/) + + // See the sidebar is correctly opened + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('heading', { name: 'folder' }) + .should('be.visible') + + // see the folder was not changed + getRowForFileId(imageId).should('exist') + }) + + it('does not open unknown file types but shows details', () => { + cy.visit(`/apps/files/files/${archiveId}?openfile`) + + // See the URL was replaced + cy.url() + .should('match', /[?&]opendetails(&|=|$)/) + .and('not.match', /openfile/) + + // See the sidebar is correctly opened + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('heading', { name: 'archive.zst' }) + .should('be.visible') + + // See no file was downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(join(downloadsFolder, 'archive.zst')).should('not.exist') + }) + }) +}) diff --git a/cypress/e2e/files/scrolling.cy.ts b/cypress/e2e/files/scrolling.cy.ts new file mode 100644 index 00000000000..f7a4ef683f5 --- /dev/null +++ b/cypress/e2e/files/scrolling.cy.ts @@ -0,0 +1,284 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { calculateViewportHeight, enableGridMode, getRowForFile } from './FilesUtils.ts' +import { beFullyInViewport, notBeFullyInViewport } from '../core-utils.ts' + +describe('files: Scrolling to selected file in file list', () => { + const fileIds = new Map<number, string>() + let viewportHeight: number + + before(() => { + initFilesAndViewport(fileIds) + .then((_viewportHeight) => { + cy.log(`Saving viewport height to ${_viewportHeight}px`) + viewportHeight = _viewportHeight + }) + }) + + beforeEach(() => { + cy.viewport(1200, viewportHeight) + }) + + it('Can see first file in list', () => { + cy.visit(`/apps/files/files/${fileIds.get(1)}`) + + // See file is visible + getRowForFile('1.txt') + .should('be.visible') + + // we expect also element 6 to be visible + getRowForFile('6.txt') + .should('be.visible') + // but not element 7 - though it should exist (be buffered) + getRowForFile('7.txt') + .should('exist') + .and('not.be.visible') + }) + + // Same kind of tests for partially visible top and bottom + for (let i = 2; i <= 5; i++) { + it(`correctly scrolls to row ${i}`, () => { + cy.visit(`/apps/files/files/${fileIds.get(i)}`) + + // See file is visible + getRowForFile(`${i}.txt`) + .should('be.visible') + .and(notBeOverlappedByTableHeader) + + // we expect also element +4 to be visible + // (6 visible rows -> 5 without our scrolled row -> so we only have 4 fully visible others + two 1/2 hidden rows) + getRowForFile(`${i + 4}.txt`) + .should('be.visible') + // but not element -1 or +5 - though it should exist (be buffered) + getRowForFile(`${i - 1}.txt`) + .should('exist') + .and(beOverlappedByTableHeader) + getRowForFile(`${i + 5}.txt`) + .should('exist') + .and(notBeFullyInViewport) + }) + } + + // this will have half of the footer visible and half of the previous element + it('correctly scrolls to row 6', () => { + cy.visit(`/apps/files/files/${fileIds.get(6)}`) + + // See file is visible + getRowForFile('6.txt') + .should('be.visible') + .and(notBeOverlappedByTableHeader) + + // we expect also element 7,8,9,10 visible + getRowForFile('10.txt') + .should('be.visible') + // but not row 5 + getRowForFile('5.txt') + .should('exist') + .and(beOverlappedByTableHeader) + // see footer is only shown partly + cy.get('tfoot') + .should('exist') + .and(notBeFullyInViewport) + .contains('10 files') + .should('be.visible') + }) + + // For the last "page" of entries we can not scroll further + // so we show all of the last 4 entries + for (let i = 7; i <= 10; i++) { + it(`correctly scrolls to row ${i}`, () => { + cy.visit(`/apps/files/files/${fileIds.get(i)}`) + + // See file is visible + getRowForFile(`${i}.txt`) + .should('be.visible') + .and(notBeOverlappedByTableHeader) + + // there are only max. 4 rows left so also row 6+ should be visible + getRowForFile('6.txt') + .should('be.visible') + getRowForFile('10.txt') + .should('be.visible') + // Also the footer is visible + cy.get('tfoot') + .contains('10 files') + .should(beFullyInViewport) + }) + } +}) + +describe('files: Scrolling to selected file in file list (GRID MODE)', () => { + const fileIds = new Map<number, string>() + let viewportHeight: number + + before(() => { + initFilesAndViewport(fileIds, true) + .then((_viewportHeight) => { viewportHeight = _viewportHeight }) + }) + + beforeEach(() => { + cy.viewport(768, viewportHeight) + }) + + // First row + for (let i = 1; i <= 3; i++) { + it(`Can see files in first row (file ${i})`, () => { + cy.visit(`/apps/files/files/${fileIds.get(i)}`) + + for (let j = 1; j <= 3; j++) { + // See all files of that row are visible + getRowForFile(`${j}.txt`) + .should('be.visible') + // we expect also the second row to be visible + getRowForFile(`${j + 3}.txt`) + .should('be.visible') + // Because there is no half row on top we also see the third row + getRowForFile(`${j + 6}.txt`) + .should('be.visible') + // But not the forth row + getRowForFile(`${j + 9}.txt`) + .should('exist') + .and(notBeFullyInViewport) + } + }) + } + + // Second row + // Same kind of tests for partially visible top and bottom + for (let i = 4; i <= 6; i++) { + it(`correctly scrolls to second row (file ${i})`, () => { + cy.visit(`/apps/files/files/${fileIds.get(i)}`) + + // See all three files of that row are visible + for (let j = 4; j <= 6; j++) { + getRowForFile(`${j}.txt`) + .should('be.visible') + .and(notBeOverlappedByTableHeader) + // we expect also the next row to be visible + getRowForFile(`${j + 3}.txt`) + .should('be.visible') + // but not the row below (should be half cut) + getRowForFile(`${j + 6}.txt`) + .should('exist') + .and(notBeFullyInViewport) + // Same for the row above + getRowForFile(`${j - 3}.txt`) + .should('exist') + .and(beOverlappedByTableHeader) + } + }) + } + + // Third row + // this will have half of the footer visible and half of the previous row + for (let i = 7; i <= 9; i++) { + it(`correctly scrolls to third row (file ${i})`, () => { + cy.visit(`/apps/files/files/${fileIds.get(i)}`) + + // See all three files of that row are visible + for (let j = 7; j <= 9; j++) { + getRowForFile(`${j}.txt`) + .should('be.visible') + // we expect also the next row to be visible + getRowForFile(`${j + 3}.txt`) + .should('be.visible') + // but not the row above + getRowForFile(`${j - 3}.txt`) + .should('exist') + .and(beOverlappedByTableHeader) + } + + cy.get('tfoot') + .contains('span', '12 files') + .should('be.visible') + }) + } + + // Forth row which only has row 4 and 3 visible and the full footer + for (let i = 10; i <= 12; i++) { + it(`correctly scrolls to forth row (file ${i})`, () => { + cy.visit(`/apps/files/files/${fileIds.get(i)}`) + + // See all three files of that row are visible + for (let j = 10; j <= 12; j++) { + getRowForFile(`${j}.txt`) + .should('be.visible') + .and(notBeOverlappedByTableHeader) + // we expect also the row above to be visible + getRowForFile(`${j - 3}.txt`) + .should('be.visible') + } + + // see footer is shown + cy.get('tfoot') + .contains('.files-list__row-name', '12 files') + .should(beFullyInViewport) + }) + } +}) + +/// Some helpers + +/** + * Assert that an element is overlapped by the table header + * @param $el The element + * @param expected if it should be overlapped or NOT + */ +function beOverlappedByTableHeader($el: JQuery<HTMLElement>, expected = true) { + const headerRect = Cypress.$('thead').get(0)!.getBoundingClientRect() + const elementRect = $el.get(0)!.getBoundingClientRect() + const overlap = !(headerRect.right < elementRect.left + || headerRect.left > elementRect.right + || headerRect.bottom < elementRect.top + || headerRect.top > elementRect.bottom) + + if (expected) { + // eslint-disable-next-line no-unused-expressions + expect(overlap, 'Overlapped by table header').to.be.true + } else { + // eslint-disable-next-line no-unused-expressions + expect(overlap, 'Not overlapped by table header').to.be.false + } +} + +/** + * Assert that an element is not overlapped by the table header + * @param $el The element + */ +function notBeOverlappedByTableHeader($el: JQuery<HTMLElement>) { + return beOverlappedByTableHeader($el, false) +} + +function initFilesAndViewport(fileIds: Map<number, string>, gridMode = false): Cypress.Chainable<number> { + return cy.createRandomUser().then((user) => { + cy.rm(user, '/welcome.txt') + + // Create files with names 1.txt, 2.txt, ..., 10.txt + const count = gridMode ? 12 : 10 + for (let i = 1; i <= count; i++) { + cy.uploadContent(user, new Blob([]), 'text/plain', `/${i}.txt`) + .then((response) => fileIds.set(i, Number.parseInt(response.headers['oc-fileid']).toString())) + } + + cy.login(user) + cy.viewport(1200, 800) + + cy.visit('/apps/files') + + // If grid mode is requested, enable it + if (gridMode) { + enableGridMode() + } + + // Calculate height to ensure that those 10 elements can not be rendered in one list (only 6 will fit the screen, 3 in grid mode) + return calculateViewportHeight(gridMode ? 3 : 6) + .then((height) => { + // Set viewport height to the calculated height + cy.log(`Setting viewport height to ${height}px`) + cy.wrap(height) + }) + }) +} diff --git a/cypress/e2e/files/search.cy.ts b/cypress/e2e/files/search.cy.ts new file mode 100644 index 00000000000..3b5d455fd6c --- /dev/null +++ b/cypress/e2e/files/search.cy.ts @@ -0,0 +1,217 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { FilesNavigationPage } from '../../pages/FilesNavigation' +import { getRowForFile, navigateToFolder } from './FilesUtils' + +describe('files: search', () => { + + let user: User + + const navigation = new FilesNavigationPage() + + before(() => { + cy.createRandomUser().then(($user) => { + user = $user + cy.mkdir(user, '/some folder') + cy.mkdir(user, '/some folder/nested folder') + cy.mkdir(user, '/other folder') + cy.mkdir(user, '/12345') + cy.uploadContent(user, new Blob(['content']), 'text/plain', '/file.txt') + cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/a file.txt') + cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/a second file.txt') + cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/nested folder/deep file.txt') + cy.uploadContent(user, new Blob(['content']), 'text/plain', '/other folder/another file.txt') + cy.login(user) + }) + }) + + beforeEach(() => { + cy.visit('/apps/files') + }) + + it('updates the query on the URL', () => { + navigation.searchScopeTrigger().click() + navigation.searchScopeMenu() + .should('be.visible') + .findByRole('menuitem', { name: /search everywhere/i }) + .should('be.visible') + .click() + + navigation.searchInput().type('file') + cy.url().should('match', /query=file($|&)/) + }) + + it('can search globally', () => { + navigation.searchScopeTrigger().click() + navigation.searchScopeMenu() + .should('be.visible') + .findByRole('menuitem', { name: /search everywhere/i }) + .should('be.visible') + .click() + navigation.searchInput().type('file') + + getRowForFile('file.txt').should('be.visible') + getRowForFile('a file.txt').should('be.visible') + getRowForFile('a second file.txt').should('be.visible') + getRowForFile('another file.txt').should('be.visible') + }) + + it('filter does also search locally', () => { + navigateToFolder('some folder') + getRowForFile('a file.txt').should('be.visible') + + navigation.searchInput().type('file') + + getRowForFile('a file.txt').should('be.visible') + getRowForFile('a second file.txt').should('be.visible') + getRowForFile('deep file.txt').should('be.visible') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 3) + }) + + it('See "search everywhere" button', () => { + // Not visible initially + cy.get('[data-cy-files-filters]') + .findByRole('button', { name: /Search everywhere/i }) + .should('not.to.exist') + + // add a filter + navigation.searchInput().type('file') + + // see its visible + cy.get('[data-cy-files-filters]') + .findByRole('button', { name: /Search everywhere/i }) + .should('be.visible') + + // clear the filter + navigation.searchClearButton().click() + + // see its not visible again + cy.get('[data-cy-files-filters]') + .findByRole('button', { name: /Search everywhere/i }) + .should('not.to.exist') + }) + + it('can make local search a global search', () => { + navigateToFolder('some folder') + getRowForFile('a file.txt').should('be.visible') + + navigation.searchInput().type('file') + + // see local results + getRowForFile('a file.txt').should('be.visible') + getRowForFile('a second file.txt').should('be.visible') + getRowForFile('deep file.txt').should('be.visible') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 3) + + // toggle global search + cy.get('[data-cy-files-filters]') + .findByRole('button', { name: /Search everywhere/i }) + .should('be.visible') + .click() + + // see global results + getRowForFile('file.txt').should('be.visible') + getRowForFile('a file.txt').should('be.visible') + getRowForFile('deep file.txt').should('be.visible') + getRowForFile('a second file.txt').should('be.visible') + getRowForFile('another file.txt').should('be.visible') + }) + + it('shows empty content when there are no results', () => { + navigateToFolder('some folder') + getRowForFile('a file.txt').should('be.visible') + + navigation.searchScopeTrigger().click() + navigation.searchScopeMenu() + .should('be.visible') + .findByRole('menuitem', { name: /search everywhere/i }) + .should('be.visible') + .click() + navigation.searchInput().type('xyz') + + // see the empty content message + cy.contains('[role="note"]', /No search results for .xyz./) + .should('be.visible') + .within(() => { + // see within there is a search box with the same value + cy.findByRole('searchbox', { name: /search for files/i }) + .should('be.visible') + .and('have.value', 'xyz') + }) + }) + + it('can alter search', () => { + navigation.searchScopeTrigger().click() + navigation.searchScopeMenu() + .should('be.visible') + .findByRole('menuitem', { name: /search everywhere/i }) + .should('be.visible') + .click() + navigation.searchInput().type('other') + + getRowForFile('another file.txt').should('be.visible') + getRowForFile('other folder').should('be.visible') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2) + + navigation.searchInput().type(' file') + navigation.searchInput().should('have.value', 'other file') + getRowForFile('another file.txt').should('be.visible') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1) + }) + + it('returns to file list if search is cleared', () => { + navigation.searchScopeTrigger().click() + navigation.searchScopeMenu() + .should('be.visible') + .findByRole('menuitem', { name: /search everywhere/i }) + .should('be.visible') + .click() + navigation.searchInput().type('other') + + getRowForFile('another file.txt').should('be.visible') + getRowForFile('other folder').should('be.visible') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2) + + navigation.searchClearButton().click() + navigation.searchInput().should('have.value', '') + getRowForFile('file.txt').should('be.visible') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 5) + }) + + /** + * Problem: + * 1. Being on the search view + * 2. Press the refresh button (name of the current view) + * 3. See that the router link does not preserve the query + * + * We fix this with a navigation guard and need to verify that it works + */ + it('keeps the query in the URL', () => { + navigation.searchScopeTrigger().click() + navigation.searchScopeMenu() + .should('be.visible') + .findByRole('menuitem', { name: /search everywhere/i }) + .should('be.visible') + .click() + navigation.searchInput().type('file') + + // see that the search view is loaded + getRowForFile('a file.txt').should('be.visible') + // see the correct url + cy.url().should('match', /query=file($|&)/) + + cy.intercept('SEARCH', '**/remote.php/dav/').as('search') + // refresh the view + cy.findByRole('button', { description: /reload current directory/i }).click() + // wait for the request + cy.wait('@search') + // see that the search view is reloaded + getRowForFile('a file.txt').should('be.visible') + // see the correct url + cy.url().should('match', /query=file($|&)/) + }) +}) diff --git a/cypress/e2e/files_external/StorageUtils.ts b/cypress/e2e/files_external/StorageUtils.ts new file mode 100644 index 00000000000..0f7fec65edf --- /dev/null +++ b/cypress/e2e/files_external/StorageUtils.ts @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from "@nextcloud/cypress" + +export type StorageConfig = { + [key: string]: string +} + +export enum StorageBackend { + DAV = 'dav', + SMB = 'smb', + SFTP = 'sftp', +} + +export enum AuthBackend { + GlobalAuth = 'password::global', + LoginCredentials = 'password::logincredentials', + Password = 'password::password', + SessionCredentials = 'password::sessioncredentials', + UserGlobalAuth = 'password::global::user', + UserProvided = 'password::userprovided', +} + +/** + * Create a storage via occ + */ +export function createStorageWithConfig(mountPoint: string, storageBackend: StorageBackend, authBackend: AuthBackend, configs: StorageConfig, user?: User): Cypress.Chainable { + const configsFlag = Object.keys(configs).map(key => `--config "${key}=${configs[key]}"`).join(' ') + const userFlag = user ? `--user ${user.userId}` : '' + + const command = `files_external:create "${mountPoint}" "${storageBackend}" "${authBackend}" ${configsFlag} ${userFlag}` + + cy.log(`Creating storage with command: ${command}`) + return cy.runOccCommand(command) +} diff --git a/cypress/e2e/files_external/files-external-failed.cy.ts b/cypress/e2e/files_external/files-external-failed.cy.ts new file mode 100644 index 00000000000..29e5454dd60 --- /dev/null +++ b/cypress/e2e/files_external/files-external-failed.cy.ts @@ -0,0 +1,75 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' +import { AuthBackend, createStorageWithConfig, StorageBackend } from './StorageUtils' +import { getRowForFile } from '../files/FilesUtils' + +describe('Files user credentials', { testIsolation: true }, () => { + let currentUser: User + + beforeEach(() => { + }) + + before(() => { + cy.runOccCommand('app:enable files_external') + cy.createRandomUser().then((user) => { currentUser = user }) + }) + + afterEach(() => { + // Cleanup global storages + cy.runOccCommand('files_external:list --output=json').then(({ stdout }) => { + const list = JSON.parse(stdout) + list.forEach((storage) => cy.runOccCommand(`files_external:delete --yes ${storage.mount_id}`), { failOnNonZeroExit: false }) + }) + }) + + after(() => { + cy.runOccCommand('app:disable files_external') + }) + + it('Create a failed user storage with invalid url', () => { + const url = 'http://cloud.domain.com/remote.php/dav/files/abcdef123456' + createStorageWithConfig('Storage1', StorageBackend.DAV, AuthBackend.LoginCredentials, { host: url.replace('index.php/', ''), secure: 'false' }) + + cy.login(currentUser) + cy.visit('/apps/files') + + // Ensure the row is visible and marked as unavailable + getRowForFile('Storage1').as('row').should('be.visible') + cy.get('@row').find('[data-cy-files-list-row-name-link]') + .should('have.attr', 'title', 'This node is unavailable') + + // Ensure clicking on the location does not open the folder + cy.location().then((loc) => { + cy.get('@row').find('[data-cy-files-list-row-name-link]').click() + cy.location('href').should('eq', loc.href) + }) + }) + + it('Create a failed user storage with invalid login credentials', () => { + const url = 'http://cloud.domain.com/remote.php/dav/files/abcdef123456' + createStorageWithConfig('Storage2', StorageBackend.DAV, AuthBackend.Password, { + host: url.replace('index.php/', ''), + user: 'invaliduser', + password: 'invalidpassword', + secure: 'false', + }) + + cy.login(currentUser) + cy.visit('/apps/files') + + // Ensure the row is visible and marked as unavailable + getRowForFile('Storage2').as('row').should('be.visible') + cy.get('@row').find('[data-cy-files-list-row-name-link]') + .should('have.attr', 'title', 'This node is unavailable') + + // Ensure clicking on the location does not open the folder + cy.location().then((loc) => { + cy.get('@row').find('[data-cy-files-list-row-name-link]').click() + cy.location('href').should('eq', loc.href) + }) + }) +}) diff --git a/cypress/e2e/files_external/files-user-credentials.cy.ts b/cypress/e2e/files_external/files-user-credentials.cy.ts new file mode 100644 index 00000000000..b20b06b69ba --- /dev/null +++ b/cypress/e2e/files_external/files-user-credentials.cy.ts @@ -0,0 +1,143 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' +import { AuthBackend, createStorageWithConfig, StorageBackend } from './StorageUtils' +import { getInlineActionEntryForFile, getRowForFile, navigateToFolder, triggerInlineActionForFile } from '../files/FilesUtils' + +import { ACTION_CREDENTIALS_EXTERNAL_STORAGE } from '../../../apps/files_external/src/actions/enterCredentialsAction' +import { handlePasswordConfirmation } from '../settings/usersUtils' + +describe('Files user credentials', { testIsolation: true }, () => { + let user1: User + let user2: User + let storageUser: User + + before(() => { + cy.runOccCommand('app:enable files_external') + + // Create some users + cy.createRandomUser().then((user) => { user1 = user }) + cy.createRandomUser().then((user) => { user2 = user }) + + // This user will hold the webdav storage + cy.createRandomUser().then((user) => { + storageUser = user + cy.uploadFile(user, 'image.jpg') + }) + }) + + after(() => { + // Cleanup global storages + cy.runOccCommand('files_external:list --output=json').then(({ stdout }) => { + const list = JSON.parse(stdout) + list.forEach((storage) => cy.runOccCommand(`files_external:delete --yes ${storage.mount_id}`), { failOnNonZeroExit: false }) + }) + + cy.runOccCommand('app:disable files_external') + }) + + it('Create a user storage with user credentials', () => { + // Its not the public server address but the address so the server itself can connect to it + const base = 'http://localhost' + const host = `${base}/remote.php/dav/files/${storageUser.userId}` + createStorageWithConfig(storageUser.userId, StorageBackend.DAV, AuthBackend.UserProvided, { host, secure: 'false' }) + + cy.login(user1) + cy.visit('/apps/files/extstoragemounts') + getRowForFile(storageUser.userId).should('be.visible') + + cy.intercept('PUT', '**/apps/files_external/userglobalstorages/*').as('setCredentials') + + triggerInlineActionForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE) + + // See credentials dialog + cy.findByRole('dialog', { name: 'Storage credentials' }).as('storageDialog') + cy.get('@storageDialog').should('be.visible') + cy.get('@storageDialog').findByRole('textbox', { name: 'Login' }).type(storageUser.userId) + cy.get('@storageDialog').get('input[type="password"]').type(storageUser.password) + cy.get('@storageDialog').get('button').contains('Confirm').click() + cy.get('@storageDialog').should('not.exist') + + // Storage dialog now closed, the user auth dialog should be visible + cy.findByRole('dialog', { name: 'Confirm your password' }).as('authDialog') + cy.get('@authDialog').should('be.visible') + handlePasswordConfirmation(user1.password) + + // Wait for the credentials to be set + cy.wait('@setCredentials') + + // Auth dialog should be closed and the set credentials button should be gone + cy.get('@authDialog').should('not.exist', { timeout: 2000 }) + + getInlineActionEntryForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE) + .should('not.exist') + + // Finally, the storage should be accessible + cy.visit('/apps/files') + navigateToFolder(storageUser.userId) + getRowForFile('image.jpg').should('be.visible') + }) + + it('Create a user storage with GLOBAL user credentials', () => { + // Its not the public server address but the address so the server itself can connect to it + const base = 'http://localhost' + const host = `${base}/remote.php/dav/files/${storageUser.userId}` + createStorageWithConfig('storage1', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host, secure: 'false' }) + + cy.login(user2) + cy.visit('/apps/files/extstoragemounts') + getRowForFile('storage1').should('be.visible') + + cy.intercept('PUT', '**/apps/files_external/userglobalstorages/*').as('setCredentials') + + triggerInlineActionForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE) + + // See credentials dialog + cy.findByRole('dialog', { name: 'Storage credentials' }).as('storageDialog') + cy.get('@storageDialog').should('be.visible') + cy.get('@storageDialog').findByRole('textbox', { name: 'Login' }).type(storageUser.userId) + cy.get('@storageDialog').get('input[type="password"]').type(storageUser.password) + cy.get('@storageDialog').get('button').contains('Confirm').click() + cy.get('@storageDialog').should('not.exist') + + // Storage dialog now closed, the user auth dialog should be visible + cy.findByRole('dialog', { name: 'Confirm your password' }).as('authDialog') + cy.get('@authDialog').should('be.visible') + handlePasswordConfirmation(user2.password) + + // Wait for the credentials to be set + cy.wait('@setCredentials') + + // Auth dialog should be closed and the set credentials button should be gone + cy.get('@authDialog').should('not.exist', { timeout: 2000 }) + getInlineActionEntryForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') + + // Finally, the storage should be accessible + cy.visit('/apps/files') + navigateToFolder('storage1') + getRowForFile('image.jpg').should('be.visible') + }) + + it('Create another user storage while reusing GLOBAL user credentials', () => { + // Its not the public server address but the address so the server itself can connect to it + const base = 'http://localhost' + const host = `${base}/remote.php/dav/files/${storageUser.userId}` + createStorageWithConfig('storage2', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host, secure: 'false' }) + + cy.login(user2) + cy.visit('/apps/files/extstoragemounts') + getRowForFile('storage2').should('be.visible') + + // Since we already have set the credentials, the action should not be present + getInlineActionEntryForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') + getInlineActionEntryForFile('storage2', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') + + // Finally, the storage should be accessible + cy.visit('/apps/files') + navigateToFolder('storage2') + getRowForFile('image.jpg').should('be.visible') + }) +}) diff --git a/cypress/e2e/files_external/settings.cy.ts b/cypress/e2e/files_external/settings.cy.ts new file mode 100644 index 00000000000..9f017bbf951 --- /dev/null +++ b/cypress/e2e/files_external/settings.cy.ts @@ -0,0 +1,130 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +describe('files_external settings', () => { + before(() => { + cy.runOccCommand('app:enable files_external') + cy.login({ language: 'en', password: 'admin', userId: 'admin' }) + }) + + beforeEach(() => { + cy.runOccCommand('files_external:list --output json') + .then((exec) => { + const list = JSON.parse(exec.stdout) + for (const entry of list) { + cy.runOccCommand('files_external:delete ' + entry) + } + }) + cy.visit('/settings/admin/externalstorages') + }) + + it('can see the settings section', () => { + cy.findByRole('heading', { name: 'External storage' }) + .should('be.visible') + cy.get('table#externalStorage') + .should('be.visible') + }) + + it('populates the row and creates a new empty one', () => { + selectBackend('local') + + // See cell now contains the backend + getTable() + .findAllByRole('row') + .first() + .find('.backend') + .should('contain.text', 'Local') + + // and the backend select is available but clear + getBackendSelect() + .should('have.value', null) + + // the suggested mount point name is set to the backend + getTable() + .findAllByRole('row') + .first() + .find('input[name="mountPoint"]') + .should('have.value', 'Local') + }) + + it('does not save the storage with missing configuration', function() { + selectBackend('local') + + getTable() + .findAllByRole('row').first() + .should('be.visible') + .within(() => { + cy.findByRole('checkbox', { name: 'All people' }) + .check() + cy.get('button[title="Save"]') + .click() + }) + + cy.findByRole('dialog', { name: 'Confirm your password' }) + .should('not.exist') + }) + + it('does not save the storage with applicable configuration', function() { + selectBackend('local') + + getTable() + .findAllByRole('row').first() + .should('be.visible') + .within(() => { + cy.get('input[placeholder="Location"]') + .type('/tmp') + cy.get('button[title="Save"]') + .click() + }) + + cy.findByRole('dialog', { name: 'Confirm your password' }) + .should('not.exist') + }) + + it('does save the storage with needed configuration', function() { + selectBackend('local') + + getTable() + .findAllByRole('row').first() + .should('be.visible') + .within(() => { + cy.findByRole('checkbox', { name: 'All people' }) + .check() + cy.get('input[placeholder="Location"]') + .type('/tmp') + cy.get('button[title="Save"]') + .click() + }) + + cy.findByRole('dialog', { name: 'Confirm your password' }) + .should('be.visible') + }) +}) + +/** + * Get the external storages table + */ +function getTable() { + return cy.get('table#externalStorage') + .find('tbody') +} + +/** + * Get the backend select element + */ +function getBackendSelect() { + return getTable() + .findAllByRole('row') + .last() + .findByRole('combobox') +} + +/** + * @param backend - Backend to select + */ +function selectBackend(backend: string): void { + getBackendSelect() + .select(backend) +} diff --git a/cypress/e2e/files_sharing/FilesSharingUtils.ts b/cypress/e2e/files_sharing/FilesSharingUtils.ts new file mode 100644 index 00000000000..c9b30bd576c --- /dev/null +++ b/cypress/e2e/files_sharing/FilesSharingUtils.ts @@ -0,0 +1,199 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +/* eslint-disable jsdoc/require-jsdoc */ +import { triggerActionForFile } from '../files/FilesUtils' + +export interface ShareSetting { + read: boolean + update: boolean + delete: boolean + create: boolean + share: boolean + download: boolean + note: string + expiryDate: Date +} + +export function createShare(fileName: string, username: string, shareSettings: Partial<ShareSetting> = {}) { + openSharingPanel(fileName) + + cy.get('#app-sidebar-vue').within(() => { + cy.intercept({ times: 1, method: 'GET', url: '**/apps/files_sharing/api/v1/sharees?*' }).as('userSearch') + cy.findByRole('combobox', { name: /Search for internal recipients/i }) + .type(`{selectAll}${username}`) + cy.wait('@userSearch') + }) + + cy.get(`[user="${username}"]`).click() + + // HACK: Save the share and then update it, as permissions changes are currently not saved for new share. + cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' }) + updateShare(fileName, 0, shareSettings) +} + +export function openSharingDetails(index: number) { + cy.get('#app-sidebar-vue').within(() => { + cy.get('[data-cy-files-sharing-share-actions]').eq(index).click({ force: true }) + cy.get('[data-cy-files-sharing-share-permissions-bundle="custom"]').click() + }) +} + +export function updateShare(fileName: string, index: number, shareSettings: Partial<ShareSetting> = {}) { + openSharingPanel(fileName) + openSharingDetails(index) + + cy.intercept({ times: 1, method: 'PUT', url: '**/apps/files_sharing/api/v1/shares/*' }).as('updateShare') + + cy.get('#app-sidebar-vue').within(() => { + if (shareSettings.download !== undefined) { + cy.get('[data-cy-files-sharing-share-permissions-checkbox="download"]').find('input').as('downloadCheckbox') + if (shareSettings.download) { + // Force:true because the checkbox is hidden by the pretty UI. + cy.get('@downloadCheckbox').check({ force: true, scrollBehavior: 'nearest' }) + } else { + // Force:true because the checkbox is hidden by the pretty UI. + cy.get('@downloadCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) + } + } + + if (shareSettings.read !== undefined) { + cy.get('[data-cy-files-sharing-share-permissions-checkbox="read"]').find('input').as('readCheckbox') + if (shareSettings.read) { + // Force:true because the checkbox is hidden by the pretty UI. + cy.get('@readCheckbox').check({ force: true, scrollBehavior: 'nearest' }) + } else { + // Force:true because the checkbox is hidden by the pretty UI. + cy.get('@readCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) + } + } + + if (shareSettings.update !== undefined) { + cy.get('[data-cy-files-sharing-share-permissions-checkbox="update"]').find('input').as('updateCheckbox') + if (shareSettings.update) { + // Force:true because the checkbox is hidden by the pretty UI. + cy.get('@updateCheckbox').check({ force: true, scrollBehavior: 'nearest' }) + } else { + // Force:true because the checkbox is hidden by the pretty UI. + cy.get('@updateCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) + } + } + + if (shareSettings.create !== undefined) { + cy.get('[data-cy-files-sharing-share-permissions-checkbox="create"]').find('input').as('createCheckbox') + if (shareSettings.create) { + // Force:true because the checkbox is hidden by the pretty UI. + cy.get('@createCheckbox').check({ force: true, scrollBehavior: 'nearest' }) + } else { + // Force:true because the checkbox is hidden by the pretty UI. + cy.get('@createCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) + } + } + + if (shareSettings.delete !== undefined) { + cy.get('[data-cy-files-sharing-share-permissions-checkbox="delete"]').find('input').as('deleteCheckbox') + if (shareSettings.delete) { + // Force:true because the checkbox is hidden by the pretty UI. + cy.get('@deleteCheckbox').check({ force: true, scrollBehavior: 'nearest' }) + } else { + // Force:true because the checkbox is hidden by the pretty UI. + cy.get('@deleteCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) + } + } + + if (shareSettings.note !== undefined) { + cy.findByRole('checkbox', { name: /note to recipient/i }).check({ force: true, scrollBehavior: 'nearest' }) + cy.findByRole('textbox', { name: /note to recipient/i }).type(shareSettings.note) + } + + if (shareSettings.expiryDate !== undefined) { + cy.findByRole('checkbox', { name: /expiration date/i }) + .check({ force: true, scrollBehavior: 'nearest' }) + cy.get('#share-date-picker') + .type(`${shareSettings.expiryDate.getFullYear()}-${String(shareSettings.expiryDate.getMonth() + 1).padStart(2, '0')}-${String(shareSettings.expiryDate.getDate()).padStart(2, '0')}`) + } + + cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' }) + + cy.wait('@updateShare') + }) + // close all toasts + cy.get('.toast-success').findAllByRole('button').click({ force: true, multiple: true }) +} + +export function openSharingPanel(fileName: string) { + triggerActionForFile(fileName, 'details') + + cy.get('[data-cy-sidebar]') + .find('[aria-controls="tab-sharing"]') + .click() +} + +type FileRequestOptions = { + label?: string + note?: string + password?: string + /* YYYY-MM-DD format */ + expiration?: string +} + +/** + * Create a file request for a folder + * @param path The path of the folder, leading slash is required + * @param options The options for the file request + */ +export const createFileRequest = (path: string, options: FileRequestOptions = {}) => { + if (!path.startsWith('/')) { + throw new Error('Path must start with a slash') + } + + // Navigate to the folder + cy.visit('/apps/files/files?dir=' + path) + + // Open the file request dialog + cy.get('[data-cy-upload-picker] .action-item__menutoggle').first().click() + cy.contains('.upload-picker__menu-entry button', 'Create file request').click() + cy.get('[data-cy-file-request-dialog]').should('be.visible') + + // Check and fill the first page options + cy.get('[data-cy-file-request-dialog-fieldset="label"]').should('be.visible') + cy.get('[data-cy-file-request-dialog-fieldset="destination"]').should('be.visible') + cy.get('[data-cy-file-request-dialog-fieldset="note"]').should('be.visible') + + cy.get('[data-cy-file-request-dialog-fieldset="destination"] input').should('contain.value', path) + if (options.label) { + cy.get('[data-cy-file-request-dialog-fieldset="label"] input').type(`{selectall}${options.label}`) + } + if (options.note) { + cy.get('[data-cy-file-request-dialog-fieldset="note"] textarea').type(`{selectall}${options.note}`) + } + + // Go to the next page + cy.get('[data-cy-file-request-dialog-controls="next"]').click() + cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="checkbox"]').should('exist') + cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="date"]').should('not.exist') + cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="checkbox"]').should('exist') + cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="password"]').should('not.exist') + if (options.expiration) { + cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="checkbox"]').check({ force: true }) + cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="date"]').type(`{selectall}${options.expiration}`) + } + if (options.password) { + cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="checkbox"]').check({ force: true }) + cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="password"]').type(`{selectall}${options.password}`) + } + + // Create the file request + cy.get('[data-cy-file-request-dialog-controls="next"]').click() + + // Get the file request URL + cy.get('[data-cy-file-request-dialog-fieldset="link"]').then(($link) => { + const url = $link.val() + cy.log(`File request URL: ${url}`) + cy.wrap(url).as('fileRequestUrl') + }) + + // Close + cy.get('[data-cy-file-request-dialog-controls="finish"]').click() +} diff --git a/cypress/e2e/files_sharing/ShareOptionsType.ts b/cypress/e2e/files_sharing/ShareOptionsType.ts new file mode 100644 index 00000000000..a6ce6922299 --- /dev/null +++ b/cypress/e2e/files_sharing/ShareOptionsType.ts @@ -0,0 +1,18 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export type ShareOptions = { + enforcePassword?: boolean + enforceExpirationDate?: boolean + alwaysAskForPassword?: boolean + defaultExpirationDateSet?: boolean +} + +export const defaultShareOptions: ShareOptions = { + enforcePassword: false, + enforceExpirationDate: false, + alwaysAskForPassword: false, + defaultExpirationDateSet: false, +} diff --git a/cypress/e2e/files_sharing/expiry-date.cy.ts b/cypress/e2e/files_sharing/expiry-date.cy.ts new file mode 100644 index 00000000000..f39a47309e2 --- /dev/null +++ b/cypress/e2e/files_sharing/expiry-date.cy.ts @@ -0,0 +1,128 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' +import { closeSidebar } from '../files/FilesUtils.ts' +import { createShare, openSharingDetails, openSharingPanel, updateShare } from './FilesSharingUtils.ts' + +describe('files_sharing: Expiry date', () => { + const expectedDefaultDate = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000) + const expectedDefaultDateString = `${expectedDefaultDate.getFullYear()}-${String(expectedDefaultDate.getMonth() + 1).padStart(2, '0')}-${String(expectedDefaultDate.getDate()).padStart(2, '0')}` + const fortnight = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) + const fortnightString = `${fortnight.getFullYear()}-${String(fortnight.getMonth() + 1).padStart(2, '0')}-${String(fortnight.getDate()).padStart(2, '0')}` + + let alice: User + let bob: User + + before(() => { + // Ensure we have the admin setting setup for default dates with 2 days in the future + cy.runOccCommand('config:app:set --value yes core shareapi_default_internal_expire_date') + cy.runOccCommand('config:app:set --value 2 core shareapi_internal_expire_after_n_days') + + cy.createRandomUser().then((user) => { + alice = user + cy.login(alice) + }) + cy.createRandomUser().then((user) => { + bob = user + }) + }) + + after(() => { + cy.runOccCommand('config:app:delete core shareapi_default_internal_expire_date') + cy.runOccCommand('config:app:delete core shareapi_enforce_internal_expire_date') + cy.runOccCommand('config:app:delete core shareapi_internal_expire_after_n_days') + }) + + beforeEach(() => { + cy.runOccCommand('config:app:delete core shareapi_enforce_internal_expire_date') + }) + + it('See default expiry date is set and enforced', () => { + // Enforce the date + cy.runOccCommand('config:app:set --value yes core shareapi_enforce_internal_expire_date') + const dir = 'defaultExpiryDateEnforced' + prepareDirectory(dir) + + validateExpiryDate(dir, expectedDefaultDateString) + cy.findByRole('checkbox', { name: /expiration date/i }) + .should('be.checked') + .and('be.disabled') + }) + + it('See default expiry date is set also if not enforced', () => { + const dir = 'defaultExpiryDate' + prepareDirectory(dir) + + validateExpiryDate(dir, expectedDefaultDateString) + cy.findByRole('checkbox', { name: /expiration date/i }) + .should('be.checked') + .and('not.be.disabled') + .check({ force: true, scrollBehavior: 'nearest' }) + }) + + it('Can set custom expiry date', () => { + const dir = 'customExpiryDate' + prepareDirectory(dir) + updateShare(dir, 0, { expiryDate: fortnight }) + validateExpiryDate(dir, fortnightString) + }) + + it('Custom expiry date survives reload', () => { + const dir = 'customExpiryDateReload' + prepareDirectory(dir) + updateShare(dir, 0, { expiryDate: fortnight }) + validateExpiryDate(dir, fortnightString) + + cy.visit('/apps/files') + validateExpiryDate(dir, fortnightString) + }) + + /** + * Regression test for https://github.com/nextcloud/server/pull/50192 + * Ensure that admin default settings do not always override the user set value. + */ + it('Custom expiry date survives unrelated update', () => { + const dir = 'customExpiryUnrelatedChanges' + prepareDirectory(dir) + updateShare(dir, 0, { expiryDate: fortnight }) + validateExpiryDate(dir, fortnightString) + + closeSidebar() + updateShare(dir, 0, { note: 'Only note changed' }) + validateExpiryDate(dir, fortnightString) + + cy.visit('/apps/files') + validateExpiryDate(dir, fortnightString) + }) + + /** + * Prepare directory, login and share to bob + * + * @param name The directory name + */ + function prepareDirectory(name: string) { + cy.mkdir(alice, `/${name}`) + cy.login(alice) + cy.visit('/apps/files') + createShare(name, bob.userId) + } + + /** + * Validate expiry date on a share + * + * @param filename The filename to validate + * @param expectedDate The expected date in YYYY-MM-dd + */ + function validateExpiryDate(filename: string, expectedDate: string) { + openSharingPanel(filename) + openSharingDetails(0) + + cy.get('#share-date-picker') + .should('exist') + .and('have.value', expectedDate) + } + +}) diff --git a/cypress/e2e/files_sharing/file-request.cy.ts b/cypress/e2e/files_sharing/file-request.cy.ts new file mode 100644 index 00000000000..578f72fa0b5 --- /dev/null +++ b/cypress/e2e/files_sharing/file-request.cy.ts @@ -0,0 +1,83 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { createFolder, getRowForFile, navigateToFolder } from '../files/FilesUtils' +import { createFileRequest } from './FilesSharingUtils' + +const enterGuestName = (name: string) => { + cy.findByRole('dialog', { name: /Upload files to/ }) + .should('be.visible') + .within(() => { + cy.findByRole('textbox', { name: 'Name' }) + .should('be.visible') + + cy.findByRole('textbox', { name: 'Name' }) + .type(`{selectall}${name}`) + + cy.findByRole('button', { name: 'Submit name' }) + .should('be.visible') + .click() + }) + + cy.findByRole('dialog', { name: /Upload files to/ }) + .should('not.exist') +} + +describe('Files', { testIsolation: true }, () => { + const folderName = 'test-folder' + let user: User + let url = '' + + it('Login with a user and create a file request', () => { + cy.createRandomUser().then((_user) => { + user = _user + cy.login(user) + }) + + cy.visit('/apps/files') + createFolder(folderName) + + createFileRequest(`/${folderName}`) + cy.get('@fileRequestUrl').should('contain', '/s/').then((_url: string) => { + cy.logout() + url = _url + }) + }) + + it('Open the file request as a guest', () => { + cy.visit(url) + enterGuestName('Guest') + + // Check various elements on the page + cy.contains(`Upload files to ${folderName}`) + .should('be.visible') + cy.findByRole('button', { name: 'Upload' }) + .should('be.visible') + + cy.intercept('PUT', '/public.php/dav/files/*/*').as('uploadFile') + + // Upload a file + cy.get('[data-cy-files-sharing-file-drop] input[type="file"]') + .should('exist') + .selectFile({ + contents: Cypress.Buffer.from('abcdef'), + fileName: 'file.txt', + mimeType: 'text/plain', + lastModified: Date.now(), + }, { force: true }) + + cy.wait('@uploadFile').its('response.statusCode').should('eq', 201) + }) + + it('Check the uploaded file', () => { + cy.login(user) + cy.visit(`/apps/files/files?dir=/${folderName}`) + getRowForFile('Guest') + .should('be.visible') + navigateToFolder('Guest') + getRowForFile('file.txt').should('be.visible') + }) +}) diff --git a/cypress/e2e/files_sharing/files-copy-move.cy.ts b/cypress/e2e/files_sharing/files-copy-move.cy.ts new file mode 100644 index 00000000000..6ad01cb2219 --- /dev/null +++ b/cypress/e2e/files_sharing/files-copy-move.cy.ts @@ -0,0 +1,150 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { createShare } from './FilesSharingUtils.ts' +import { + getRowForFile, + copyFile, + navigateToFolder, + triggerActionForFile, +} from '../files/FilesUtils.ts' +import { ACTION_COPY_MOVE } from '../../../apps/files/src/actions/moveOrCopyAction.ts' + +export const copyFileForbidden = (fileName: string, dirPath: string) => { + getRowForFile(fileName).should('be.visible') + triggerActionForFile(fileName, ACTION_COPY_MOVE) + + cy.get('.file-picker').within(() => { + // intercept the copy so we can wait for it + cy.intercept('COPY', /\/(remote|public)\.php\/dav\/files\//).as('copyFile') + + const directories = dirPath.split('/') + directories.forEach((directory) => { + // select the folder + cy.get(`[data-filename="${CSS.escape(directory)}"]`).should('be.visible').click() + }) + + // check copy button + cy.contains('button', `Copy to ${directories.at(-1)}`).should('be.disabled') + }) +} + +export const moveFileForbidden = (fileName: string, dirPath: string) => { + getRowForFile(fileName).should('be.visible') + triggerActionForFile(fileName, ACTION_COPY_MOVE) + + cy.get('.file-picker').within(() => { + // intercept the copy so we can wait for it + cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile') + + // select home folder + cy.get('button[title="Home"]').should('be.visible').click() + + const directories = dirPath.split('/') + directories.forEach((directory) => { + // select the folder + cy.get(`[data-filename="${directory}"]`).should('be.visible').click() + }) + + // click move + cy.contains('button', `Move to ${directories.at(-1)}`).should('not.exist') + }) +} + +describe('files_sharing: Move or copy files', { testIsolation: true }, () => { + let user: User + let sharee: User + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + }) + cy.createRandomUser().then(($user) => { + sharee = $user + }) + }) + + it('can create a file in a shared folder', () => { + // share the folder + cy.mkdir(user, '/folder') + cy.login(user) + cy.visit('/apps/files') + createShare('folder', sharee.userId, { read: true, download: true }) + cy.logout() + + // Now for the sharee + cy.uploadContent(sharee, new Blob([]), 'text/plain', '/folder/file.txt') + cy.login(sharee) + // visit shared files view + cy.visit('/apps/files') + // see the shared folder + getRowForFile('folder').should('be.visible') + navigateToFolder('folder') + // Content of the shared folder + getRowForFile('file.txt').should('be.visible') + }) + + it('can copy a file to a shared folder', () => { + // share the folder + cy.mkdir(user, '/folder') + cy.login(user) + cy.visit('/apps/files') + createShare('folder', sharee.userId, { read: true, download: true }) + cy.logout() + + // Now for the sharee + cy.uploadContent(sharee, new Blob([]), 'text/plain', '/file.txt') + cy.login(sharee) + // visit shared files view + cy.visit('/apps/files') + // see the shared folder + getRowForFile('folder').should('be.visible') + // copy file to a shared folder + copyFile('file.txt', 'folder') + // click on the folder should open it in files + navigateToFolder('folder') + // Content of the shared folder + getRowForFile('file.txt').should('be.visible') + }) + + it('can not copy a file to a shared folder with no create permissions', () => { + // share the folder + cy.mkdir(user, '/folder') + cy.login(user) + cy.visit('/apps/files') + createShare('folder', sharee.userId, { read: true, download: true, create: false }) + cy.logout() + + // Now for the sharee + cy.uploadContent(sharee, new Blob([]), 'text/plain', '/file.txt') + cy.login(sharee) + // visit shared files view + cy.visit('/apps/files') + // see the shared folder + getRowForFile('folder').should('be.visible') + copyFileForbidden('file.txt', 'folder') + }) + + it('can not move a file from a shared folder with no delete permissions', () => { + // share the folder + cy.mkdir(user, '/folder') + cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/file.txt') + cy.login(user) + cy.visit('/apps/files') + createShare('folder', sharee.userId, { read: true, download: true, delete: false }) + cy.logout() + + // Now for the sharee + cy.mkdir(sharee, '/folder-own') + cy.login(sharee) + // visit shared files view + cy.visit('/apps/files') + // see the shared folder + getRowForFile('folder').should('be.visible') + navigateToFolder('folder') + getRowForFile('file.txt').should('be.visible') + moveFileForbidden('file.txt', 'folder-own') + }) +}) diff --git a/cypress/e2e/files_sharing/files-download.cy.ts b/cypress/e2e/files_sharing/files-download.cy.ts new file mode 100644 index 00000000000..97ea91b7647 --- /dev/null +++ b/cypress/e2e/files_sharing/files-download.cy.ts @@ -0,0 +1,102 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { createShare } from './FilesSharingUtils.ts' +import { + getActionButtonForFile, + getActionEntryForFile, + getRowForFile, +} from '../files/FilesUtils.ts' + +describe('files_sharing: Download forbidden', { testIsolation: true }, () => { + let user: User + let sharee: User + + beforeEach(() => { + cy.runOccCommand('config:app:set --value yes core shareapi_allow_view_without_download') + cy.createRandomUser().then(($user) => { + user = $user + }) + cy.createRandomUser().then(($user) => { + sharee = $user + }) + }) + + after(() => { + cy.runOccCommand('config:app:delete core shareapi_allow_view_without_download') + }) + + it('cannot download a folder if disabled', () => { + // share the folder + cy.mkdir(user, '/folder') + cy.login(user) + cy.visit('/apps/files') + createShare('folder', sharee.userId, { read: true, download: false }) + cy.logout() + + // Now for the sharee + cy.login(sharee) + + // visit shared files view + cy.visit('/apps/files') + // see the shared folder + getActionButtonForFile('folder') + .should('be.visible') + // open the action menu + .click({ force: true }) + // see no download action + getActionEntryForFile('folder', 'download') + .should('not.exist') + + // Disable view without download option + cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download') + + // visit shared files view + cy.visit('/apps/files') + // see the shared folder + getRowForFile('folder').should('be.visible') + getActionButtonForFile('folder') + .should('be.visible') + // open the action menu + .click({ force: true }) + getActionEntryForFile('folder', 'download').should('not.exist') + }) + + it('cannot download a file if disabled', () => { + // share the folder + cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') + cy.login(user) + cy.visit('/apps/files') + createShare('file.txt', sharee.userId, { read: true, download: false }) + cy.logout() + + // Now for the sharee + cy.login(sharee) + + // visit shared files view + cy.visit('/apps/files') + // see the shared folder + getActionButtonForFile('file.txt') + .should('be.visible') + // open the action menu + .click({ force: true }) + // see no download action + getActionEntryForFile('file.txt', 'download') + .should('not.exist') + + // Disable view without download option + cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download') + + // visit shared files view + cy.visit('/apps/files') + // see the shared folder + getRowForFile('file.txt').should('be.visible') + getActionButtonForFile('file.txt') + .should('be.visible') + // open the action menu + .click({ force: true }) + getActionEntryForFile('file.txt', 'download').should('not.exist') + }) +}) diff --git a/cypress/e2e/files_sharing/files-shares-view.cy.ts b/cypress/e2e/files_sharing/files-shares-view.cy.ts new file mode 100644 index 00000000000..12a67d9ee0f --- /dev/null +++ b/cypress/e2e/files_sharing/files-shares-view.cy.ts @@ -0,0 +1,59 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { createShare } from './FilesSharingUtils.ts' +import { getRowForFile } from '../files/FilesUtils.ts' + +describe('files_sharing: Files view', { testIsolation: true }, () => { + let user: User + let sharee: User + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + }) + cy.createRandomUser().then(($user) => { + sharee = $user + }) + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/46108 + */ + it('opens a shared folder when clicking on it', () => { + cy.mkdir(user, '/folder') + cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/file') + cy.login(user) + cy.visit('/apps/files') + + // share the folder + createShare('folder', sharee.userId, { read: true, download: true }) + // visit the own shares + cy.visit('/apps/files/sharingout') + // see the shared folder + getRowForFile('folder').should('be.visible') + // click on the folder should open it in files + getRowForFile('folder').findByRole('button', { name: /open in files/i }).click() + // See the URL has changed + cy.url().should('match', /apps\/files\/files\/.+dir=\/folder/) + // Content of the shared folder + getRowForFile('file').should('be.visible') + + cy.logout() + // Now for the sharee + cy.login(sharee) + + // visit shared files view + cy.visit('/apps/files/sharingin') + // see the shared folder + getRowForFile('folder').should('be.visible') + // click on the folder should open it in files + getRowForFile('folder').findByRole('button', { name: /open in files/i }).click() + // See the URL has changed + cy.url().should('match', /apps\/files\/files\/.+dir=\/folder/) + // Content of the shared folder + getRowForFile('file').should('be.visible') + }) +}) diff --git a/cypress/e2e/files_sharing/filesSharingUtils.ts b/cypress/e2e/files_sharing/filesSharingUtils.ts deleted file mode 100644 index cb407153380..00000000000 --- a/cypress/e2e/files_sharing/filesSharingUtils.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* eslint-disable jsdoc/require-jsdoc */ -/** - * @copyright Copyright (c) 2024 Louis Chemineau <louis@chmn.me> - * - * @author Louis Chemineau <louis@chmn.me> - * - * @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 { triggerActionForFile } from '../files/FilesUtils' - -export interface ShareSetting { - read: boolean - update: boolean - delete: boolean - share: boolean - download: boolean -} - -export function createShare(fileName: string, username: string, shareSettings: Partial<ShareSetting> = {}) { - openSharingPanel(fileName) - - cy.get('#app-sidebar-vue').within(() => { - cy.get('#sharing-search-input').clear() - cy.intercept({ times: 1, method: 'GET', url: '**/apps/files_sharing/api/v1/sharees?*' }).as('userSearch') - cy.get('#sharing-search-input').type(username) - cy.wait('@userSearch') - }) - - cy.get(`[user="${username}"]`).click() - - // HACK: Save the share and then update it, as permissions changes are currently not saved for new share. - cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' }) - updateShare(fileName, 0, shareSettings) -} - -export function updateShare(fileName: string, index: number, shareSettings: Partial<ShareSetting> = {}) { - openSharingPanel(fileName) - - cy.get('#app-sidebar-vue').within(() => { - cy.get('[data-cy-files-sharing-share-actions]').eq(index).click() - cy.get('[data-cy-files-sharing-share-permissions-bundle="custom"]').click() - - if (shareSettings.download !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="download"]').find('input').as('downloadCheckbox') - if (shareSettings.download) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@downloadCheckbox').check({ force: true, scrollBehavior: 'nearest' }) - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@downloadCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) - } - } - - if (shareSettings.read !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="read"]').find('input').as('readCheckbox') - if (shareSettings.read) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@readCheckbox').check({ force: true, scrollBehavior: 'nearest' }) - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@readCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) - } - } - - if (shareSettings.update !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="update"]').find('input').as('updateCheckbox') - if (shareSettings.update) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@updateCheckbox').check({ force: true, scrollBehavior: 'nearest' }) - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@updateCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) - } - } - - if (shareSettings.delete !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="delete"]').find('input').as('deleteCheckbox') - if (shareSettings.delete) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@deleteCheckbox').check({ force: true, scrollBehavior: 'nearest' }) - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@deleteCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) - } - } - - cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' }) - }) -} - -export function openSharingPanel(fileName: string) { - triggerActionForFile(fileName, 'details') - - cy.get('#app-sidebar-vue') - .get('[aria-controls="tab-sharing"]') - .click() -} diff --git a/cypress/e2e/files_sharing/limit_to_same_group.cy.ts b/cypress/e2e/files_sharing/limit_to_same_group.cy.ts new file mode 100644 index 00000000000..c95efa089ff --- /dev/null +++ b/cypress/e2e/files_sharing/limit_to_same_group.cy.ts @@ -0,0 +1,107 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from "@nextcloud/cypress" +import { createShare } from "./FilesSharingUtils.ts" + +describe('Limit to sharing to people in the same group', () => { + let alice: User + let bob: User + let randomFileName1 = '' + let randomFileName2 = '' + let randomGroupName = '' + let randomGroupName2 = '' + let randomGroupName3 = '' + + before(() => { + randomFileName1 = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt' + randomFileName2 = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt' + randomGroupName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + randomGroupName2 = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + randomGroupName3 = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + + cy.runOccCommand('config:app:set core shareapi_only_share_with_group_members --value yes') + + cy.createRandomUser() + .then(user => { + alice = user + cy.createRandomUser() + }) + .then(user => { + bob = user + + cy.runOccCommand(`group:add ${randomGroupName}`) + cy.runOccCommand(`group:add ${randomGroupName2}`) + cy.runOccCommand(`group:add ${randomGroupName3}`) + cy.runOccCommand(`group:adduser ${randomGroupName} ${alice.userId}`) + cy.runOccCommand(`group:adduser ${randomGroupName} ${bob.userId}`) + cy.runOccCommand(`group:adduser ${randomGroupName2} ${alice.userId}`) + cy.runOccCommand(`group:adduser ${randomGroupName2} ${bob.userId}`) + cy.runOccCommand(`group:adduser ${randomGroupName3} ${bob.userId}`) + + cy.uploadContent(alice, new Blob(['share to bob'], { type: 'text/plain' }), 'text/plain', `/${randomFileName1}`) + cy.uploadContent(bob, new Blob(['share by bob'], { type: 'text/plain' }), 'text/plain', `/${randomFileName2}`) + + cy.login(alice) + cy.visit('/apps/files') + createShare(randomFileName1, bob.userId) + cy.login(bob) + cy.visit('/apps/files') + createShare(randomFileName2, alice.userId) + }) + }) + + after(() => { + cy.runOccCommand('config:app:set core shareapi_only_share_with_group_members --value no') + }) + + it('Alice can see the shared file', () => { + cy.login(alice) + cy.visit('/apps/files') + cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName2}"]`).should('exist') + }) + + it('Bob can see the shared file', () => { + cy.login(alice) + cy.visit('/apps/files') + cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName1}"]`).should('exist') + }) + + context('Bob is removed from the first group', () => { + before(() => { + cy.runOccCommand(`group:removeuser ${randomGroupName} ${bob.userId}`) + }) + + it('Alice can see the shared file', () => { + cy.login(alice) + cy.visit('/apps/files') + cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName2}"]`).should('exist') + }) + + it('Bob can see the shared file', () => { + cy.login(alice) + cy.visit('/apps/files') + cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName1}"]`).should('exist') + }) + }) + + context('Bob is removed from the second group', () => { + before(() => { + cy.runOccCommand(`group:removeuser ${randomGroupName2} ${bob.userId}`) + }) + + it('Alice cannot see the shared file', () => { + cy.login(alice) + cy.visit('/apps/files') + cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName2}"]`).should('not.exist') + }) + + it('Bob cannot see the shared file', () => { + cy.login(alice) + cy.visit('/apps/files') + cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName1}"]`).should('not.exist') + }) + }) +}) diff --git a/cypress/e2e/files_sharing/note-to-recipient.cy.ts b/cypress/e2e/files_sharing/note-to-recipient.cy.ts new file mode 100644 index 00000000000..08fee587d9a --- /dev/null +++ b/cypress/e2e/files_sharing/note-to-recipient.cy.ts @@ -0,0 +1,92 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { createShare, openSharingPanel } from './FilesSharingUtils.ts' +import { navigateToFolder } from '../files/FilesUtils.ts' + +describe('files_sharing: Note to recipient', { testIsolation: true }, () => { + let user: User + let sharee: User + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + }) + cy.createRandomUser().then(($user) => { + sharee = $user + }) + }) + + it('displays the note to the sharee', () => { + cy.mkdir(user, '/folder') + cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/file') + cy.login(user) + cy.visit('/apps/files') + + // share the folder + createShare('folder', sharee.userId, { read: true, download: true, note: 'Hello, this is the note.' }) + + cy.logout() + // Now for the sharee + cy.login(sharee) + + // visit shared files view + cy.visit('/apps/files') + navigateToFolder('folder') + cy.get('.note-to-recipient') + .should('be.visible') + .and('contain.text', 'Hello, this is the note.') + }) + + it('displays the note to the sharee even if the file list is empty', () => { + cy.mkdir(user, '/folder') + cy.login(user) + cy.visit('/apps/files') + + // share the folder + createShare('folder', sharee.userId, { read: true, download: true, note: 'Hello, this is the note.' }) + + cy.logout() + // Now for the sharee + cy.login(sharee) + + // visit shared files view + cy.visit('/apps/files') + navigateToFolder('folder') + cy.get('.note-to-recipient') + .should('be.visible') + .and('contain.text', 'Hello, this is the note.') + }) + + /** + * Regression test for https://github.com/nextcloud/server/issues/46188 + */ + it('shows an existing note when editing a share', () => { + cy.mkdir(user, '/folder') + cy.login(user) + cy.visit('/apps/files') + + // share the folder + createShare('folder', sharee.userId, { read: true, download: true, note: 'Hello, this is the note.' }) + + // reload just to be sure + cy.visit('/apps/files') + + // open the sharing tab + openSharingPanel('folder') + + cy.get('[data-cy-sidebar]').within(() => { + // Open the share + cy.get('[data-cy-files-sharing-share-actions]').first().click({ force: true }) + + cy.findByRole('checkbox', { name: /note to recipient/i }) + .and('be.checked') + cy.findByRole('textbox', { name: /note to recipient/i }) + .should('be.visible') + .and('have.value', 'Hello, this is the note.') + }) + }) + +}) diff --git a/cypress/e2e/files_sharing/public-share/PublicShareUtils.ts b/cypress/e2e/files_sharing/public-share/PublicShareUtils.ts new file mode 100644 index 00000000000..e0cbd06a4c7 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/PublicShareUtils.ts @@ -0,0 +1,191 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import type { ShareOptions } from '../ShareOptionsType.ts' +import { openSharingPanel } from '../FilesSharingUtils.ts' + +export interface ShareContext { + user: User + url?: string +} + +const defaultShareContext: ShareContext = { + user: {} as User, + url: undefined, +} + +/** + * Retrieves the URL of the share. + * Throws an error if the share context is not initialized properly. + * + * @param context The current share context (defaults to `defaultShareContext` if not provided). + * @return The share URL. + * @throws Error if the share context has no URL. + */ +export function getShareUrl(context: ShareContext = defaultShareContext): string { + if (!context.url) { + throw new Error('You need to setup the share first!') + } + return context.url +} + +/** + * Setup the available data + * @param user The current share context + * @param shareName The name of the shared folder + */ +export function setupData(user: User, shareName: string): void { + cy.mkdir(user, `/${shareName}`) + cy.mkdir(user, `/${shareName}/subfolder`) + cy.uploadContent(user, new Blob(['<content>foo</content>']), 'text/plain', `/${shareName}/foo.txt`) + cy.uploadContent(user, new Blob(['<content>bar</content>']), 'text/plain', `/${shareName}/subfolder/bar.txt`) +} + +/** + * Check the password state based on enforcement and default presence. + * + * @param enforced Whether the password is enforced. + * @param alwaysAskForPassword Wether the password should always be asked for. + */ +function checkPasswordState(enforced: boolean, alwaysAskForPassword: boolean) { + if (enforced) { + cy.contains('Password protection (enforced)').should('exist') + } else if (alwaysAskForPassword) { + cy.contains('Password protection').should('exist') + } + cy.contains('Enter a password') + .should('exist') + .and('not.be.disabled') +} + +/** + * Check the expiration date state based on enforcement and default presence. + * + * @param enforced Whether the expiration date is enforced. + * @param hasDefault Whether a default expiration date is set. + */ +function checkExpirationDateState(enforced: boolean, hasDefault: boolean) { + if (enforced) { + cy.contains('Enable link expiration (enforced)').should('exist') + } else if (hasDefault) { + cy.contains('Enable link expiration').should('exist') + } + cy.contains('Enter expiration date') + .should('exist') + .and('not.be.disabled') + cy.get('input[data-cy-files-sharing-expiration-date-input]').should('exist') + cy.get('input[data-cy-files-sharing-expiration-date-input]') + .invoke('val') + .then((val) => { + // eslint-disable-next-line no-unused-expressions + expect(val).to.not.be.undefined + + const inputDate = new Date(typeof val === 'number' ? val : String(val)) + const expectedDate = new Date() + expectedDate.setDate(expectedDate.getDate() + 2) + expect(inputDate.toDateString()).to.eq(expectedDate.toDateString()) + }) + +} + +/** + * Create a public link share + * @param context The current share context + * @param shareName The name of the shared folder + * @param options The share options + */ +export function createLinkShare(context: ShareContext, shareName: string, options: ShareOptions | null = null): Cypress.Chainable<string> { + cy.login(context.user) + cy.visit('/apps/files') + openSharingPanel(shareName) + + cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createLinkShare') + cy.findByRole('button', { name: 'Create a new share link' }).click() + // Conduct optional checks based on the provided options + if (options) { + cy.get('.sharing-entry__actions').should('be.visible') // Wait for the dialog to open + checkPasswordState(options.enforcePassword ?? false, options.alwaysAskForPassword ?? false) + checkExpirationDateState(options.enforceExpirationDate ?? false, options.defaultExpirationDateSet ?? false) + cy.findByRole('button', { name: 'Create share' }).click() + } + + return cy.wait('@createLinkShare') + .should(({ response }) => { + expect(response?.statusCode).to.eq(200) + const url = response?.body?.ocs?.data?.url + expect(url).to.match(/^https?:\/\//) + context.url = url + }) + .then(() => cy.wrap(context.url as string)) +} + +/** + * open link share details for specific index + * + * @param index + */ +export function openLinkShareDetails(index: number) { + cy.findByRole('list', { name: 'Link shares' }) + .findAllByRole('listitem') + .eq(index) + .findByRole('button', { name: /Actions/i }) + .click() + cy.findByRole('menuitem', { name: /Customize link/i }).click() +} + +/** + * Adjust share permissions to be editable + */ +function adjustSharePermission(): void { + openLinkShareDetails(0) + + cy.get('[data-cy-files-sharing-share-permissions-bundle]').should('be.visible') + cy.get('[data-cy-files-sharing-share-permissions-bundle="upload-edit"]').click() + + cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') + cy.findByRole('button', { name: 'Update share' }).click() + cy.wait('@updateShare').its('response.statusCode').should('eq', 200) +} + +/** + * Setup a public share and backup the state. + * If the setup was already done in another run, the state will be restored. + * + * @param shareName The name of the shared folder + * @return The URL of the share + */ +export function setupPublicShare(shareName = 'shared'): Cypress.Chainable<string> { + + return cy.task('getVariable', { key: 'public-share-data' }) + .then((data) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { dataSnapshot, shareUrl } = data as any || {} + if (dataSnapshot) { + cy.restoreState(dataSnapshot) + defaultShareContext.url = shareUrl + return cy.wrap(shareUrl as string) + } else { + const shareData: Record<string, unknown> = {} + return cy.createRandomUser() + .then((user) => { + defaultShareContext.user = user + }) + .then(() => setupData(defaultShareContext.user, shareName)) + .then(() => createLinkShare(defaultShareContext, shareName)) + .then((url) => { + shareData.shareUrl = url + }) + .then(() => adjustSharePermission()) + .then(() => + cy.saveState().then((snapshot) => { + shareData.dataSnapshot = snapshot + }), + ) + .then(() => cy.task('setVariable', { key: 'public-share-data', value: shareData })) + .then(() => cy.log(`Public share setup, URL: ${shareData.shareUrl}`)) + .then(() => cy.wrap(defaultShareContext.url)) + } + }) +} diff --git a/cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts b/cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts new file mode 100644 index 00000000000..87f16b01387 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts @@ -0,0 +1,49 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { copyFile, getRowForFile, moveFile, navigateToFolder } from '../../files/FilesUtils.ts' +import { getShareUrl, setupPublicShare } from './PublicShareUtils.ts' + +describe('files_sharing: Public share - copy and move files', { testIsolation: true }, () => { + + beforeEach(() => { + setupPublicShare() + .then(() => cy.logout()) + .then(() => cy.visit(getShareUrl())) + }) + + it('Can copy a file to new folder', () => { + getRowForFile('foo.txt').should('be.visible') + getRowForFile('subfolder').should('be.visible') + + copyFile('foo.txt', 'subfolder') + + // still visible + getRowForFile('foo.txt').should('be.visible') + navigateToFolder('subfolder') + + cy.url().should('contain', 'dir=/subfolder') + getRowForFile('foo.txt').should('be.visible') + getRowForFile('bar.txt').should('be.visible') + getRowForFile('subfolder').should('not.exist') + }) + + it('Can move a file to new folder', () => { + getRowForFile('foo.txt').should('be.visible') + getRowForFile('subfolder').should('be.visible') + + moveFile('foo.txt', 'subfolder') + + // wait until visible again + getRowForFile('subfolder').should('be.visible') + + // file should be moved -> not exist anymore + getRowForFile('foo.txt').should('not.exist') + navigateToFolder('subfolder') + + cy.url().should('contain', 'dir=/subfolder') + getRowForFile('foo.txt').should('be.visible') + getRowForFile('subfolder').should('not.exist') + }) +}) diff --git a/cypress/e2e/files_sharing/public-share/default-view.cy.ts b/cypress/e2e/files_sharing/public-share/default-view.cy.ts new file mode 100644 index 00000000000..33e0a57da11 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/default-view.cy.ts @@ -0,0 +1,102 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { getRowForFile } from '../../files/FilesUtils.ts' +import { createLinkShare, setupData } from './PublicShareUtils.ts' + +describe('files_sharing: Public share - setting the default view mode', () => { + + let user: User + + beforeEach(() => { + cy.createRandomUser() + .then(($user) => (user = $user)) + .then(() => setupData(user, 'shared')) + }) + + it('is by default in list view', () => { + const context = { user } + createLinkShare(context, 'shared') + .then((url) => { + cy.logout() + cy.visit(url!) + + // See file is visible + getRowForFile('foo.txt').should('be.visible') + // See we are in list view + cy.findByRole('button', { name: 'Switch to grid view' }) + .should('be.visible') + .and('not.be.disabled') + }) + }) + + it('can be toggled by user', () => { + const context = { user } + createLinkShare(context, 'shared') + .then((url) => { + cy.logout() + cy.visit(url!) + + // See file is visible + getRowForFile('foo.txt') + .should('be.visible') + // See we are in list view + .find('.files-list__row-icon') + .should(($el) => expect($el.outerWidth()).to.be.lessThan(99)) + + // See the grid view toggle + cy.findByRole('button', { name: 'Switch to grid view' }) + .should('be.visible') + .and('not.be.disabled') + // And can change to grid view + .click() + + // See we are in grid view + getRowForFile('foo.txt') + .find('.files-list__row-icon') + .should(($el) => expect($el.outerWidth()).to.be.greaterThan(99)) + + // See the grid view toggle is now the list view toggle + cy.findByRole('button', { name: 'Switch to list view' }) + .should('be.visible') + .and('not.be.disabled') + }) + }) + + it('can be changed to default grid view', () => { + const context = { user } + createLinkShare(context, 'shared') + .then((url) => { + // Can set the "grid" view checkbox + cy.findByRole('list', { name: 'Link shares' }) + .findAllByRole('listitem') + .first() + .findByRole('button', { name: /Actions/i }) + .click() + cy.findByRole('menuitem', { name: /Customize link/i }).click() + cy.findByRole('checkbox', { name: /Show files in grid view/i }) + .scrollIntoView() + cy.findByRole('checkbox', { name: /Show files in grid view/i }) + .should('not.be.checked') + .check({ force: true }) + + // Wait for the share update + cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') + cy.findByRole('button', { name: 'Update share' }).click() + cy.wait('@updateShare').its('response.statusCode').should('eq', 200) + + // Logout and visit the share + cy.logout() + cy.visit(url!) + + // See file is visible + getRowForFile('foo.txt').should('be.visible') + // See we are in list view + cy.findByRole('button', { name: 'Switch to list view' }) + .should('be.visible') + .and('not.be.disabled') + }) + }) +}) diff --git a/cypress/e2e/files_sharing/public-share/download.cy.ts b/cypress/e2e/files_sharing/public-share/download.cy.ts new file mode 100644 index 00000000000..372f553a8a0 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/download.cy.ts @@ -0,0 +1,266 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +// @ts-expect-error The package is currently broken - but works... +import { deleteDownloadsFolderBeforeEach } from 'cypress-delete-downloads-folder' +import { createLinkShare, getShareUrl, openLinkShareDetails, setupPublicShare, type ShareContext } from './PublicShareUtils.ts' +import { getRowForFile, getRowForFileId, triggerActionForFile, triggerActionForFileId } from '../../files/FilesUtils.ts' +import { zipFileContains } from '../../../support/utils/assertions.ts' +import type { User } from '@nextcloud/cypress' + +describe('files_sharing: Public share - downloading files', { testIsolation: true }, () => { + + // in general there is no difference except downloading + // as file shares have the source of the share token but a different displayname + describe('file share', () => { + let fileId: number + + before(() => { + cy.createRandomUser().then((user) => { + const context: ShareContext = { user } + cy.uploadContent(user, new Blob(['<content>foo</content>']), 'text/plain', '/file.txt') + .then(({ headers }) => { fileId = Number.parseInt(headers['oc-fileid']) }) + cy.login(user) + createLinkShare(context, 'file.txt') + .then(() => cy.logout()) + .then(() => cy.visit(context.url!)) + }) + }) + + it('can download the file', () => { + getRowForFileId(fileId) + .should('be.visible') + getRowForFileId(fileId) + .find('[data-cy-files-list-row-name]') + .should((el) => expect(el.text()).to.match(/file\s*\.txt/)) // extension is sparated so there might be a space between + triggerActionForFileId(fileId, 'download') + // check a file is downloaded with the correct name + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/file.txt`, 'utf-8', { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 5) + .and('contain', '<content>foo</content>') + }) + }) + + describe('folder share', () => { + before(() => setupPublicShare()) + + deleteDownloadsFolderBeforeEach() + + beforeEach(() => { + cy.logout() + cy.visit(getShareUrl()) + }) + + it('Can download all files', () => { + getRowForFile('foo.txt').should('be.visible') + + cy.get('[data-cy-files-list]').within(() => { + cy.findByRole('checkbox', { name: /Toggle selection for all files/i }) + .should('exist') + .check({ force: true }) + + // see that two files are selected + cy.contains('2 selected').should('be.visible') + + // click download + cy.findByRole('button', { name: 'Download (selected)' }) + .should('be.visible') + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/download.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + 'foo.txt', + 'subfolder/', + 'subfolder/bar.txt', + ])) + }) + }) + + it('Can download selected files', () => { + getRowForFile('subfolder') + .should('be.visible') + + cy.get('[data-cy-files-list]').within(() => { + getRowForFile('subfolder') + .findByRole('checkbox') + .check({ force: true }) + + // see that two files are selected + cy.contains('1 selected').should('be.visible') + + // click download + cy.findByRole('button', { name: 'Download (selected)' }) + .should('be.visible') + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + 'subfolder/', + 'subfolder/bar.txt', + ])) + }) + }) + + it('Can download folder by action', () => { + getRowForFile('subfolder') + .should('be.visible') + + cy.get('[data-cy-files-list]').within(() => { + triggerActionForFile('subfolder', 'download') + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + 'subfolder/', + 'subfolder/bar.txt', + ])) + }) + }) + + it('Can download file by action', () => { + getRowForFile('foo.txt') + .should('be.visible') + + cy.get('[data-cy-files-list]').within(() => { + triggerActionForFile('foo.txt', 'download') + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/foo.txt`, 'utf-8', { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 5) + .and('contain', '<content>foo</content>') + }) + }) + + it('Can download file by selection', () => { + getRowForFile('foo.txt') + .should('be.visible') + + cy.get('[data-cy-files-list]').within(() => { + getRowForFile('foo.txt') + .findByRole('checkbox') + .check({ force: true }) + + cy.findByRole('button', { name: 'Download (selected)' }) + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/foo.txt`, 'utf-8', { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 5) + .and('contain', '<content>foo</content>') + }) + }) + }) + + describe('download permission - link share', () => { + let context: ShareContext + beforeEach(() => { + cy.createRandomUser().then((user) => { + cy.mkdir(user, '/test') + + context = { user } + createLinkShare(context, 'test') + cy.login(context.user) + cy.visit('/apps/files') + }) + }) + + deleteDownloadsFolderBeforeEach() + + it('download permission is retained', () => { + getRowForFile('test').should('be.visible') + triggerActionForFile('test', 'details') + + openLinkShareDetails(0) + + cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('update') + + cy.findByRole('checkbox', { name: /hide download/i }) + .should('exist') + .and('not.be.checked') + .check({ force: true }) + cy.findByRole('checkbox', { name: /hide download/i }) + .should('be.checked') + cy.findByRole('button', { name: /update share/i }) + .click() + + cy.wait('@update') + + openLinkShareDetails(0) + cy.findByRole('checkbox', { name: /hide download/i }) + .should('be.checked') + + cy.reload() + + openLinkShareDetails(0) + cy.findByRole('checkbox', { name: /hide download/i }) + .should('be.checked') + }) + }) + + describe('download permission - mail share', () => { + let user: User + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + cy.mkdir(user, '/test') + cy.login(user) + cy.visit('/apps/files') + }) + }) + + it('download permission is retained', () => { + getRowForFile('test').should('be.visible') + triggerActionForFile('test', 'details') + + cy.findByRole('combobox', { name: /Enter external recipients/i }) + .type('test@example.com') + + cy.get('.option[sharetype="4"][user="test@example.com"]') + .parent('li') + .click() + cy.findByRole('button', { name: /advanced settings/i }) + .should('be.visible') + .click() + + cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('update') + + cy.findByRole('checkbox', { name: /hide download/i }) + .should('exist') + .and('not.be.checked') + .check({ force: true }) + cy.findByRole('button', { name: /save share/i }) + .click() + + cy.wait('@update') + + openLinkShareDetails(0) + cy.findByRole('button', { name: /advanced settings/i }) + .click() + cy.findByRole('checkbox', { name: /hide download/i }) + .should('exist') + .and('be.checked') + }) + }) +}) diff --git a/cypress/e2e/files_sharing/public-share/header-avatar.cy.ts b/cypress/e2e/files_sharing/public-share/header-avatar.cy.ts new file mode 100644 index 00000000000..c7227062293 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/header-avatar.cy.ts @@ -0,0 +1,193 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { ShareContext } from './PublicShareUtils.ts' +import { createLinkShare, setupData } from './PublicShareUtils.ts' + +/** + * This tests ensures that on public shares the header avatar menu correctly works + */ +describe('files_sharing: Public share - header avatar menu', { testIsolation: true }, () => { + let context: ShareContext + let firstPublicShareUrl = '' + let secondPublicShareUrl = '' + + before(() => { + cy.createRandomUser() + .then((user) => { + context = { + user, + url: undefined, + } + setupData(context.user, 'public1') + setupData(context.user, 'public2') + createLinkShare(context, 'public1').then((shareUrl) => { + firstPublicShareUrl = shareUrl + cy.log(`Created first share with URL: ${shareUrl}`) + }) + createLinkShare(context, 'public2').then((shareUrl) => { + secondPublicShareUrl = shareUrl + cy.log(`Created second share with URL: ${shareUrl}`) + }) + }) + }) + + beforeEach(() => { + cy.logout() + cy.visit(firstPublicShareUrl) + }) + + it('See the undefined avatar menu', () => { + cy.get('header') + .findByRole('navigation', { name: /User menu/i }) + .should('be.visible') + .findByRole('button', { name: /User menu/i }) + .should('be.visible') + .click() + cy.get('#header-menu-public-page-user-menu') + .as('headerMenu') + + // Note that current guest user is not identified + cy.get('@headerMenu') + .should('be.visible') + .findByRole('note') + .should('be.visible') + .should('contain', 'not identified') + + // Button to set guest name + cy.get('@headerMenu') + .findByRole('link', { name: /Set public name/i }) + .should('be.visible') + }) + + it('Can set public name', () => { + cy.get('header') + .findByRole('navigation', { name: /User menu/i }) + .should('be.visible') + .findByRole('button', { name: /User menu/i }) + .should('be.visible') + .as('userMenuButton') + + // Open the user menu + cy.get('@userMenuButton').click() + cy.get('#header-menu-public-page-user-menu') + .as('headerMenu') + + cy.get('@headerMenu') + .findByRole('link', { name: /Set public name/i }) + .should('be.visible') + .click() + + // Check the dialog is visible + cy.findByRole('dialog', { name: /Guest identification/i }) + .should('be.visible') + .as('guestIdentificationDialog') + + // Check the note is visible + cy.get('@guestIdentificationDialog') + .findByRole('note') + .should('contain', 'not identified') + + // Check the input is visible + cy.get('@guestIdentificationDialog') + .findByRole('textbox', { name: /Name/i }) + .should('be.visible') + .type('{selectAll}John Doe{enter}') + + // Check that the dialog is closed + cy.get('@guestIdentificationDialog') + .should('not.exist') + + // Check that the avatar changed + cy.get('@userMenuButton') + .find('img') + .invoke('attr', 'src') + .should('include', 'avatar/guest/John%20Doe') + }) + + it('Guest name us persistent and can be changed', () => { + cy.get('header') + .findByRole('navigation', { name: /User menu/i }) + .should('be.visible') + .findByRole('button', { name: /User menu/i }) + .should('be.visible') + .as('userMenuButton') + + // Open the user menu + cy.get('@userMenuButton').click() + cy.get('#header-menu-public-page-user-menu') + .as('headerMenu') + + cy.get('@headerMenu') + .findByRole('link', { name: /Set public name/i }) + .should('be.visible') + .click() + + // Check the dialog is visible + cy.findByRole('dialog', { name: /Guest identification/i }) + .should('be.visible') + .as('guestIdentificationDialog') + + // Set the name + cy.get('@guestIdentificationDialog') + .findByRole('textbox', { name: /Name/i }) + .should('be.visible') + .type('{selectAll}Jane Doe{enter}') + + // Check that the dialog is closed + cy.get('@guestIdentificationDialog') + .should('not.exist') + + // Create another share + cy.visit(secondPublicShareUrl) + + cy.get('header') + .findByRole('navigation', { name: /User menu/i }) + .should('be.visible') + .findByRole('button', { name: /User menu/i }) + .should('be.visible') + .as('userMenuButton') + + // Open the user menu + cy.get('@userMenuButton').click() + cy.get('#header-menu-public-page-user-menu') + .as('headerMenu') + + // See the note with the current name + cy.get('@headerMenu') + .findByRole('note') + .should('contain', 'You will be identified as Jane Doe') + + cy.get('@headerMenu') + .findByRole('link', { name: /Change public name/i }) + .should('be.visible') + .click() + + // Check the dialog is visible + cy.findByRole('dialog', { name: /Guest identification/i }) + .should('be.visible') + .as('guestIdentificationDialog') + + // Check that the note states the current name + // cy.get('@guestIdentificationDialog') + // .findByRole('note') + // .should('contain', 'are currently identified as Jane Doe') + + // Change the name + cy.get('@guestIdentificationDialog') + .findByRole('textbox', { name: /Name/i }) + .should('be.visible') + .type('{selectAll}Foo Bar{enter}') + + // Check that the dialog is closed + cy.get('@guestIdentificationDialog') + .should('not.exist') + + // Check that the avatar changed with the second name + cy.get('@userMenuButton') + .find('img') + .invoke('attr', 'src') + .should('include', 'avatar/guest/Foo%20Bar') + }) +}) diff --git a/cypress/e2e/files_sharing/public-share/header-menu.cy.ts b/cypress/e2e/files_sharing/public-share/header-menu.cy.ts new file mode 100644 index 00000000000..1dd0de13477 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/header-menu.cy.ts @@ -0,0 +1,199 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { haveValidity, zipFileContains } from '../../../support/utils/assertions.ts' +import { getShareUrl, setupPublicShare } from './PublicShareUtils.ts' + +/** + * This tests ensures that on public shares the header actions menu correctly works + */ +describe('files_sharing: Public share - header actions menu', { testIsolation: true }, () => { + + before(() => setupPublicShare()) + beforeEach(() => { + cy.logout() + cy.visit(getShareUrl()) + }) + + it('Can download all files', () => { + cy.get('header') + .findByRole('button', { name: 'Download' }) + .should('be.visible') + cy.get('header') + .findByRole('button', { name: 'Download' }) + .click() + + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/shared.zip`, null, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 30) + // Check all files are included + .and(zipFileContains([ + 'shared/', + 'shared/foo.txt', + 'shared/subfolder/', + 'shared/subfolder/bar.txt', + ])) + }) + + it('Can copy direct link', () => { + // Check the button + cy.get('header') + .findByRole('button', { name: /More actions/i }) + .should('be.visible') + cy.get('header') + .findByRole('button', { name: /More actions/i }) + .click() + // See the menu + cy.findByRole('menu', { name: /More action/i }) + .should('be.visible') + // see correct link in item + cy.findByRole('menuitem', { name: 'Direct link' }) + .should('be.visible') + .and('have.attr', 'href') + .then((attribute) => expect(attribute).to.match(new RegExp(`^${Cypress.env('baseUrl')}/public.php/dav/files/.+/?accept=zip$`))) + // see menu closes on click + cy.findByRole('menuitem', { name: 'Direct link' }) + .click() + cy.findByRole('menu', { name: /More actions/i }) + .should('not.exist') + }) + + it('Can create federated share', () => { + // Check the button + cy.get('header') + .findByRole('button', { name: /More actions/i }) + .should('be.visible') + cy.get('header') + .findByRole('button', { name: /More actions/i }) + .click() + // See the menu + cy.findByRole('menu', { name: /More action/i }) + .should('be.visible') + // see correct button + cy.findByRole('menuitem', { name: /Add to your/i }) + .should('be.visible') + .click() + // see the dialog + cy.findByRole('dialog', { name: /Add to your Nextcloud/i }) + .should('be.visible') + cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).within(() => { + cy.findByRole('textbox') + .type('user@nextcloud.local') + // create share + cy.intercept('POST', '**/apps/federatedfilesharing/createFederatedShare') + .as('createFederatedShare') + cy.findByRole('button', { name: 'Create share' }) + .click() + cy.wait('@createFederatedShare') + }) + }) + + it('Has user feedback while creating federated share', () => { + // Check the button + cy.get('header') + .findByRole('button', { name: /More actions/i }) + .should('be.visible') + .click() + // see correct button + cy.findByRole('menuitem', { name: /Add to your/i }) + .should('be.visible') + .click() + // see the dialog + cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).should('be.visible').within(() => { + cy.findByRole('textbox') + .type('user@nextcloud.local') + // intercept request, the request is continued when the promise is resolved + const { promise, resolve } = Promise.withResolvers() + cy.intercept('POST', '**/apps/federatedfilesharing/createFederatedShare', (request) => { + // we need to wait in the onResponse handler as the intercept handler times out otherwise + request.on('response', async (response) => { await promise; response.statusCode = 503 }) + }).as('createFederatedShare') + + // create the share + cy.findByRole('button', { name: 'Create share' }) + .click() + // see that while the share is created the button is disabled + cy.findByRole('button', { name: 'Create share' }) + .should('be.disabled') + .then(() => { + // continue the request + resolve(null) + }) + cy.wait('@createFederatedShare') + // see that the button is no longer disabled + cy.findByRole('button', { name: 'Create share' }) + .should('not.be.disabled') + }) + }) + + it('Has input validation for federated share', () => { + // Check the button + cy.get('header') + .findByRole('button', { name: /More actions/i }) + .should('be.visible') + .click() + // see correct button + cy.findByRole('menuitem', { name: /Add to your/i }) + .should('be.visible') + .click() + // see the dialog + cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).should('be.visible').within(() => { + // Check domain only + cy.findByRole('textbox') + .type('nextcloud.local') + cy.findByRole('textbox') + .should(haveValidity(/user/i)) + // Check no valid domain + cy.findByRole('textbox') + .type('{selectAll}user@invalid') + cy.findByRole('textbox') + .should(haveValidity(/invalid.+url/i)) + }) + }) + + it('See primary action is moved to menu on small screens', () => { + cy.viewport(490, 490) + // Check the button does not exist + cy.get('header').within(() => { + cy.findByRole('button', { name: 'Direct link' }) + .should('not.exist') + cy.findByRole('button', { name: 'Download' }) + .should('not.exist') + cy.findByRole('button', { name: /Add to your/i }) + .should('not.exist') + // Open the menu + cy.findByRole('button', { name: /More actions/i }) + .should('be.visible') + .click() + }) + + // See correct number of menu item + cy.findByRole('menu', { name: 'More actions' }) + .findAllByRole('menuitem') + .should('have.length', 3) + cy.findByRole('menu', { name: 'More actions' }) + .within(() => { + // See that download, federated share and direct link are moved to the menu + cy.findByRole('menuitem', { name: /^Download/ }) + .should('be.visible') + cy.findByRole('menuitem', { name: /Add to your/i }) + .should('be.visible') + cy.findByRole('menuitem', { name: 'Direct link' }) + .should('be.visible') + + // See that direct link works + cy.findByRole('menuitem', { name: 'Direct link' }) + .should('be.visible') + .and('have.attr', 'href') + .then((attribute) => expect(attribute).to.match(new RegExp(`^${Cypress.env('baseUrl')}/public.php/dav/files/.+/?accept=zip$`))) + // See remote share works + cy.findByRole('menuitem', { name: /Add to your/i }) + .should('be.visible') + .click() + }) + cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).should('be.visible') + }) +}) diff --git a/cypress/e2e/files_sharing/public-share/rename-files.cy.ts b/cypress/e2e/files_sharing/public-share/rename-files.cy.ts new file mode 100644 index 00000000000..adeb6e52504 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/rename-files.cy.ts @@ -0,0 +1,32 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getRowForFile, haveValidity, triggerActionForFile } from '../../files/FilesUtils.ts' +import { getShareUrl, setupPublicShare } from './PublicShareUtils.ts' + +describe('files_sharing: Public share - renaming files', { testIsolation: true }, () => { + + beforeEach(() => { + setupPublicShare() + .then(() => cy.logout()) + .then(() => cy.visit(getShareUrl())) + }) + + it('can rename a file', () => { + // All are visible by default + getRowForFile('foo.txt').should('be.visible') + + triggerActionForFile('foo.txt', 'rename') + + getRowForFile('foo.txt') + .findByRole('textbox', { name: 'Filename' }) + .should('be.visible') + .type('{selectAll}other.txt') + .should(haveValidity('')) + .type('{enter}') + + // See it is renamed + getRowForFile('other.txt').should('be.visible') + }) +}) diff --git a/cypress/e2e/files_sharing/public-share/required-before-create.cy.ts b/cypress/e2e/files_sharing/public-share/required-before-create.cy.ts new file mode 100644 index 00000000000..772b7fa8380 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/required-before-create.cy.ts @@ -0,0 +1,192 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ShareContext } from './PublicShareUtils.ts' +import type { ShareOptions } from '../ShareOptionsType.ts' +import { defaultShareOptions } from '../ShareOptionsType.ts' +import { setupData, createLinkShare } from './PublicShareUtils.ts' + +describe('files_sharing: Before create checks', () => { + + let shareContext: ShareContext + + before(() => { + // Setup data for the shared folder once before all tests + cy.createRandomUser().then((randomUser) => { + shareContext = { + user: randomUser, + } + }) + }) + + afterEach(() => { + cy.runOccCommand('config:app:delete core shareapi_enable_link_password_by_default') + cy.runOccCommand('config:app:delete core shareapi_enforce_links_password') + cy.runOccCommand('config:app:delete core shareapi_default_expire_date') + cy.runOccCommand('config:app:delete core shareapi_enforce_expire_date') + cy.runOccCommand('config:app:delete core shareapi_expire_after_n_days') + }) + + const applyShareOptions = (options: ShareOptions = defaultShareOptions): void => { + cy.runOccCommand(`config:app:set --value ${options.alwaysAskForPassword ? 'yes' : 'no'} core shareapi_enable_link_password_by_default`) + cy.runOccCommand(`config:app:set --value ${options.enforcePassword ? 'yes' : 'no'} core shareapi_enforce_links_password`) + cy.runOccCommand(`config:app:set --value ${options.enforceExpirationDate ? 'yes' : 'no'} core shareapi_enforce_expire_date`) + cy.runOccCommand(`config:app:set --value ${options.defaultExpirationDateSet ? 'yes' : 'no'} core shareapi_default_expire_date`) + if (options.defaultExpirationDateSet) { + cy.runOccCommand('config:app:set --value 2 core shareapi_expire_after_n_days') + } + } + + it('Checks if user can create share when both password and expiration date are enforced', () => { + const shareOptions : ShareOptions = { + alwaysAskForPassword: true, + enforcePassword: true, + enforceExpirationDate: true, + defaultExpirationDateSet: true, + } + applyShareOptions(shareOptions) + const shareName = 'passwordAndExpireEnforced' + setupData(shareContext.user, shareName) + createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { + shareContext.url = shareUrl + cy.log(`Created share with URL: ${shareUrl}`) + }) + }) + + it('Checks if user can create share when password is enforced and expiration date has a default set', () => { + const shareOptions : ShareOptions = { + alwaysAskForPassword: true, + enforcePassword: true, + defaultExpirationDateSet: true, + } + applyShareOptions(shareOptions) + const shareName = 'passwordEnforcedDefaultExpire' + setupData(shareContext.user, shareName) + createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { + shareContext.url = shareUrl + cy.log(`Created share with URL: ${shareUrl}`) + }) + }) + + it('Checks if user can create share when password is optionally requested and expiration date is enforced', () => { + const shareOptions : ShareOptions = { + alwaysAskForPassword: true, + defaultExpirationDateSet: true, + enforceExpirationDate: true, + } + applyShareOptions(shareOptions) + const shareName = 'defaultPasswordExpireEnforced' + setupData(shareContext.user, shareName) + createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { + shareContext.url = shareUrl + cy.log(`Created share with URL: ${shareUrl}`) + }) + }) + + it('Checks if user can create share when password is optionally requested and expiration date have defaults set', () => { + const shareOptions : ShareOptions = { + alwaysAskForPassword: true, + defaultExpirationDateSet: true, + } + applyShareOptions(shareOptions) + const shareName = 'defaultPasswordAndExpire' + setupData(shareContext.user, shareName) + createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { + shareContext.url = shareUrl + cy.log(`Created share with URL: ${shareUrl}`) + }) + }) + + it('Checks if user can create share with password enforced and expiration date set but not enforced', () => { + const shareOptions : ShareOptions = { + alwaysAskForPassword: true, + enforcePassword: true, + defaultExpirationDateSet: true, + enforceExpirationDate: false, + } + applyShareOptions(shareOptions) + const shareName = 'passwordEnforcedExpireSetNotEnforced' + setupData(shareContext.user, shareName) + createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { + shareContext.url = shareUrl + cy.log(`Created share with URL: ${shareUrl}`) + }) + }) + + it('Checks if user can create a share when both password and expiration date have default values but are both not enforced', () => { + const shareOptions : ShareOptions = { + alwaysAskForPassword: true, + enforcePassword: false, + defaultExpirationDateSet: true, + enforceExpirationDate: false, + } + applyShareOptions(shareOptions) + const shareName = 'defaultPasswordAndExpirationNotEnforced' + setupData(shareContext.user, shareName) + createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { + shareContext.url = shareUrl + cy.log(`Created share with URL: ${shareUrl}`) + }) + }) + + it('Checks if user can create share with password not enforced but expiration date enforced', () => { + const shareOptions : ShareOptions = { + alwaysAskForPassword: true, + enforcePassword: false, + defaultExpirationDateSet: true, + enforceExpirationDate: true, + } + applyShareOptions(shareOptions) + const shareName = 'noPasswordExpireEnforced' + setupData(shareContext.user, shareName) + createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { + shareContext.url = shareUrl + cy.log(`Created share with URL: ${shareUrl}`) + }) + }) + + it('Checks if user can create share with password not enforced and expiration date has a default set', () => { + const shareOptions : ShareOptions = { + alwaysAskForPassword: true, + enforcePassword: false, + defaultExpirationDateSet: true, + enforceExpirationDate: false, + } + applyShareOptions(shareOptions) + const shareName = 'defaultExpireNoPasswordEnforced' + setupData(shareContext.user, shareName) + createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { + shareContext.url = shareUrl + cy.log(`Created share with URL: ${shareUrl}`) + }) + }) + + it('Checks if user can create share with expiration date set and password not enforced', () => { + const shareOptions : ShareOptions = { + alwaysAskForPassword: true, + enforcePassword: false, + defaultExpirationDateSet: true, + } + applyShareOptions(shareOptions) + + const shareName = 'noPasswordExpireDefault' + setupData(shareContext.user, shareName) + createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { + shareContext.url = shareUrl + cy.log(`Created share with URL: ${shareUrl}`) + }) + }) + + it('Checks if user can create share with password not enforced, expiration date not enforced, and no defaults set', () => { + applyShareOptions() + const shareName = 'noPasswordNoExpireNoDefaults' + setupData(shareContext.user, shareName) + createLinkShare(shareContext, shareName, null).then((shareUrl) => { + shareContext.url = shareUrl + cy.log(`Created share with URL: ${shareUrl}`) + }) + }) + +}) diff --git a/cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts b/cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts new file mode 100644 index 00000000000..6b026717fd8 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts @@ -0,0 +1,45 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from "@nextcloud/cypress" +import { createShare } from "./FilesSharingUtils" +import { createLinkShare, openLinkShareDetails } from "./PublicShareUtils" + +describe('files_sharing: sidebar tab', () => { + let alice: User + + beforeEach(() => { + cy.createRandomUser() + .then((user) => { + alice = user + cy.mkdir(user, '/test') + cy.login(user) + cy.visit('/apps/files') + }) + }) + + /** + * Regression tests of https://github.com/nextcloud/server/issues/53566 + * Where the ' char was shown as ' + */ + it('correctly lists shares by label with special characters', () => { + createLinkShare({ user: alice }, 'test') + openLinkShareDetails(0) + cy.findByRole('textbox', { name: /share label/i }) + .should('be.visible') + .type('Alice\' share') + + cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('PUT') + cy.findByRole('button', { name: /update share/i }).click() + cy.wait('@PUT') + + // see the label is shown correctly + cy.findByRole('list', { name: /link shares/i }) + .findAllByRole('listitem') + .should('have.length', 1) + .first() + .should('contain.text', 'Share link (Alice\' share)') + }) +}) diff --git a/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts b/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts new file mode 100644 index 00000000000..f95115ee591 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts @@ -0,0 +1,172 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getRowForFile } from '../../files/FilesUtils.ts' +import { openSharingPanel } from '../FilesSharingUtils.ts' + +describe('files_sharing: Public share - File drop', { testIsolation: true }, () => { + + let shareUrl: string + let user: string + const shareName = 'shared' + + before(() => { + cy.createRandomUser().then(($user) => { + user = $user.userId + cy.mkdir($user, `/${shareName}`) + cy.uploadContent($user, new Blob(['content']), 'text/plain', `/${shareName}/foo.txt`) + cy.login($user) + // open the files app + cy.visit('/apps/files') + // open the sidebar + openSharingPanel(shareName) + // create the share + cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') + cy.findByRole('button', { name: 'Create a new share link' }) + .click() + // extract the link + cy.wait('@createShare').should(({ response }) => { + const { ocs } = response?.body ?? {} + shareUrl = ocs?.data.url + expect(shareUrl).to.match(/^http:\/\//) + }) + + // Update the share to be a file drop + cy.findByRole('list', { name: 'Link shares' }) + .findAllByRole('listitem') + .first() + .findByRole('button', { name: /Actions/i }) + .click() + cy.findByRole('menuitem', { name: /Customize link/i }) + .should('be.visible') + .click() + cy.get('[data-cy-files-sharing-share-permissions-bundle]') + .should('be.visible') + cy.get('[data-cy-files-sharing-share-permissions-bundle="file-drop"]') + .click() + + // save the update + cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') + cy.findByRole('button', { name: 'Update share' }) + .click() + cy.wait('@updateShare') + }) + }) + + beforeEach(() => { + cy.logout() + cy.visit(shareUrl) + }) + + it('Cannot see share content', () => { + cy.contains(`Upload files to ${shareName}`) + .should('be.visible') + + // foo exists + cy.userFileExists(user, `${shareName}/foo.txt`).should('be.gt', 0) + // but is not visible + getRowForFile('foo.txt') + .should('not.exist') + }) + + it('Can only see upload files and upload folders menu entries', () => { + cy.contains(`Upload files to ${shareName}`) + .should('be.visible') + + cy.findByRole('button', { name: 'New' }) + .should('be.visible') + .click() + // See upload actions + cy.findByRole('menuitem', { name: 'Upload files' }) + .should('be.visible') + cy.findByRole('menuitem', { name: 'Upload folders' }) + .should('be.visible') + // But no other + cy.findByRole('menu') + .findAllByRole('menuitem') + .should('have.length', 2) + }) + + it('Can only see dedicated upload button', () => { + cy.contains(`Upload files to ${shareName}`) + .should('be.visible') + + cy.findByRole('button', { name: 'Upload' }) + .should('be.visible') + .click() + // See upload actions + cy.findByRole('menuitem', { name: 'Upload files' }) + .should('be.visible') + cy.findByRole('menuitem', { name: 'Upload folders' }) + .should('be.visible') + // But no other + cy.findByRole('menu') + .findAllByRole('menuitem') + .should('have.length', 2) + }) + + it('Can upload files', () => { + cy.contains(`Upload files to ${shareName}`) + .should('be.visible') + + const { promise, resolve } = Promise.withResolvers() + cy.intercept('PUT', '**/public.php/dav/files/**', (request) => { + if (request.url.includes('first.txt')) { + // just continue the first one + request.continue() + } else { + // We delay the second one until we checked that the progress bar is visible + request.on('response', async () => { await promise }) + } + }).as('uploadFile') + + cy.get('[data-cy-files-sharing-file-drop] input[type="file"]') + .should('exist') + .selectFile([ + { fileName: 'first.txt', contents: Buffer.from('8 bytes!') }, + { fileName: 'second.md', contents: Buffer.from('x'.repeat(128)) }, + ], { force: true }) + + cy.wait('@uploadFile') + + cy.findByRole('progressbar') + .should('be.visible') + .and((el) => { expect(Number.parseInt(el.attr('value') ?? '0')).be.gte(50) }) + // continue second request + .then(() => resolve(null)) + + cy.wait('@uploadFile') + + // Check files uploaded + cy.userFileExists(user, `${shareName}/first.txt`).should('eql', 8) + cy.userFileExists(user, `${shareName}/second.md`).should('eql', 128) + }) + + describe('Terms of service', { testIsolation: true }, () => { + before(() => cy.runOccCommand('config:app:set --value \'TEST: Some disclaimer text\' --type string core shareapi_public_link_disclaimertext')) + beforeEach(() => cy.visit(shareUrl)) + after(() => cy.runOccCommand('config:app:delete core shareapi_public_link_disclaimertext')) + + it('shows ToS on file-drop view', () => { + cy.get('[data-cy-files-sharing-file-drop]') + .contains(`Upload files to ${shareName}`) + .should('be.visible') + cy.get('[data-cy-files-sharing-file-drop]') + .contains('agree to the terms of service') + .should('be.visible') + cy.findByRole('button', { name: /Terms of service/i }) + .should('be.visible') + .click() + + cy.findByRole('dialog', { name: 'Terms of service' }) + .should('contain.text', 'TEST: Some disclaimer text') + // close + .findByRole('button', { name: 'Close' }) + .click() + + cy.findByRole('dialog', { name: 'Terms of service' }) + .should('not.exist') + }) + }) +}) diff --git a/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts b/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts new file mode 100644 index 00000000000..0e2d2edab6c --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts @@ -0,0 +1,100 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getActionButtonForFile, getRowForFile, navigateToFolder } from '../../files/FilesUtils.ts' +import { openSharingPanel } from '../FilesSharingUtils.ts' + +describe('files_sharing: Public share - View only', { testIsolation: true }, () => { + + let shareUrl: string + const shareName = 'shared' + + before(() => { + cy.createRandomUser().then(($user) => { + cy.mkdir($user, `/${shareName}`) + cy.mkdir($user, `/${shareName}/subfolder`) + cy.uploadContent($user, new Blob([]), 'text/plain', `/${shareName}/foo.txt`) + cy.uploadContent($user, new Blob([]), 'text/plain', `/${shareName}/subfolder/bar.txt`) + cy.login($user) + // open the files app + cy.visit('/apps/files') + // open the sidebar + openSharingPanel(shareName) + // create the share + cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') + cy.findByRole('button', { name: 'Create a new share link' }) + .click() + // extract the link + cy.wait('@createShare').should(({ response }) => { + const { ocs } = response?.body ?? {} + shareUrl = ocs?.data.url + expect(shareUrl).to.match(/^http:\/\//) + }) + + // Update the share to be a view-only-no-download share + cy.findByRole('list', { name: 'Link shares' }) + .findAllByRole('listitem') + .first() + .findByRole('button', { name: /Actions/i }) + .click() + cy.findByRole('menuitem', { name: /Customize link/i }) + .should('be.visible') + .click() + cy.get('[data-cy-files-sharing-share-permissions-bundle]') + .should('be.visible') + cy.get('[data-cy-files-sharing-share-permissions-bundle="read-only"]') + .click() + cy.findByRole('checkbox', { name: 'Hide download' }) + .check({ force: true }) + // save the update + cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') + cy.findByRole('button', { name: 'Update share' }) + .click() + cy.wait('@updateShare') + }) + }) + + beforeEach(() => { + cy.logout() + cy.visit(shareUrl) + }) + + it('Can see the files list', () => { + // foo exists + getRowForFile('foo.txt') + .should('be.visible') + }) + + it('But no actions available', () => { + // foo exists + getRowForFile('foo.txt') + .should('be.visible') + // but no actions + getActionButtonForFile('foo.txt') + .should('not.exist') + + // TODO: We really need Viewer in the server repo. + // So we could at least test viewing images + }) + + it('Can navigate to subfolder', () => { + getRowForFile('subfolder') + .should('be.visible') + + navigateToFolder('subfolder') + + getRowForFile('bar.txt') + .should('be.visible') + + // but also no actions + getActionButtonForFile('bar.txt') + .should('not.exist') + }) + + it('Cannot upload files', () => { + // wait for file list to be ready + getRowForFile('foo.txt') + .should('be.visible') + }) +}) diff --git a/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts b/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts new file mode 100644 index 00000000000..511a1caeb09 --- /dev/null +++ b/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts @@ -0,0 +1,103 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getActionButtonForFile, getRowForFile, navigateToFolder } from '../../files/FilesUtils.ts' +import { openSharingPanel } from '../FilesSharingUtils.ts' + +describe('files_sharing: Public share - View only', { testIsolation: true }, () => { + + let shareUrl: string + const shareName = 'shared' + + before(() => { + cy.createRandomUser().then(($user) => { + cy.mkdir($user, `/${shareName}`) + cy.mkdir($user, `/${shareName}/subfolder`) + cy.uploadContent($user, new Blob(['content']), 'text/plain', `/${shareName}/foo.txt`) + cy.uploadContent($user, new Blob(['content']), 'text/plain', `/${shareName}/subfolder/bar.txt`) + cy.login($user) + // open the files app + cy.visit('/apps/files') + // open the sidebar + openSharingPanel(shareName) + // create the share + cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') + cy.findByRole('button', { name: 'Create a new share link' }) + .click() + // extract the link + cy.wait('@createShare').should(({ response }) => { + const { ocs } = response?.body ?? {} + shareUrl = ocs?.data.url + expect(shareUrl).to.match(/^http:\/\//) + }) + + // Update the share to be a view-only-no-download share + cy.findByRole('list', { name: 'Link shares' }) + .findAllByRole('listitem') + .first() + .findByRole('button', { name: /Actions/i }) + .click() + cy.findByRole('menuitem', { name: /Customize link/i }) + .should('be.visible') + .click() + cy.get('[data-cy-files-sharing-share-permissions-bundle]') + .should('be.visible') + cy.get('[data-cy-files-sharing-share-permissions-bundle="read-only"]') + .click() + // save the update + cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') + cy.findByRole('button', { name: 'Update share' }) + .click() + cy.wait('@updateShare') + }) + }) + + beforeEach(() => { + cy.logout() + cy.visit(shareUrl) + }) + + it('Can see the files list', () => { + // foo exists + getRowForFile('foo.txt') + .should('be.visible') + }) + + it('Can navigate to subfolder', () => { + getRowForFile('subfolder') + .should('be.visible') + + navigateToFolder('subfolder') + + getRowForFile('bar.txt') + .should('be.visible') + }) + + it('Cannot upload files', () => { + // wait for file list to be ready + getRowForFile('foo.txt') + .should('be.visible') + }) + + it('Only download action is actions available', () => { + getActionButtonForFile('foo.txt') + .should('be.visible') + .click() + + // Only the download action + cy.findByRole('menuitem', { name: 'Download' }) + .should('be.visible') + cy.findAllByRole('menuitem') + .should('have.length', 1) + + // Can download + cy.findByRole('menuitem', { name: 'Download' }).click() + // check a file is downloaded + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/foo.txt`, 'utf-8', { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 5) + .and('contain', 'content') + }) +}) diff --git a/cypress/e2e/files_sharing/share-status-action.cy.ts b/cypress/e2e/files_sharing/share-status-action.cy.ts new file mode 100644 index 00000000000..f02ec676573 --- /dev/null +++ b/cypress/e2e/files_sharing/share-status-action.cy.ts @@ -0,0 +1,125 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { createShare } from './FilesSharingUtils.ts' +import { closeSidebar, enableGridMode, getActionButtonForFile, getInlineActionEntryForFile, getRowForFile } from '../files/FilesUtils.ts' + +describe('files_sharing: Sharing status action', { testIsolation: true }, () => { + /** + * Regression test of https://github.com/nextcloud/server/issues/45723 + */ + it('No "shared" tag when user ID is purely numerical but there are no shares', () => { + const user = { + language: 'en', + password: 'test1234', + userId: String(Math.floor(Math.random() * 1000)), + } as User + cy.createUser(user) + cy.mkdir(user, '/folder') + cy.login(user) + + cy.visit('/apps/files') + + getRowForFile('folder') + .should('be.visible') + .find('[data-cy-files-list-row-actions]') + .findByRole('button', { name: 'Shared' }) + .should('not.exist') + }) + + it('Render quick option for sharing', () => { + cy.createRandomUser().then((user) => { + cy.mkdir(user, '/folder') + cy.login(user) + + cy.visit('/apps/files') + }) + + getRowForFile('folder') + .should('be.visible') + .find('[data-cy-files-list-row-actions]') + .findByRole('button', { name: /Sharing options/ }) + .should('be.visible') + .click() + + // check the click opened the sidebar + cy.get('[data-cy-sidebar]') + .should('be.visible') + // and ensure the sharing tab is selected + .findByRole('tab', { name: 'Sharing', selected: true }) + .should('exist') + }) + + describe('Sharing inline status action handling', () => { + let user: User + let sharee: User + + before(() => { + cy.createRandomUser().then(($user) => { + sharee = $user + }) + cy.createRandomUser().then(($user) => { + user = $user + cy.mkdir(user, '/folder') + cy.login(user) + + cy.visit('/apps/files') + getRowForFile('folder').should('be.visible') + + createShare('folder', sharee.userId) + closeSidebar() + }) + cy.logout() + }) + + it('Render inline status action for sharer', () => { + cy.login(user) + cy.visit('/apps/files') + + getInlineActionEntryForFile('folder', 'sharing-status') + .should('have.attr', 'aria-label', `Shared with ${sharee.userId}`) + .should('have.attr', 'title', `Shared with ${sharee.userId}`) + .should('be.visible') + }) + + it('Render status action in gridview for sharer', () => { + cy.login(user) + cy.visit('/apps/files') + enableGridMode() + + getRowForFile('folder') + .should('be.visible') + getActionButtonForFile('folder') + .click() + cy.findByRole('menu') + .findByRole('menuitem', { name: /shared with/i }) + .should('be.visible') + }) + + it('Render inline status action for sharee', () => { + cy.login(sharee) + cy.visit('/apps/files') + + getInlineActionEntryForFile('folder', 'sharing-status') + .should('have.attr', 'aria-label', `Shared by ${user.userId}`) + .should('be.visible') + }) + + it('Render status action in grid view for sharee', () => { + cy.login(sharee) + cy.visit('/apps/files') + + enableGridMode() + + getRowForFile('folder') + .should('be.visible') + getActionButtonForFile('folder') + .click() + cy.findByRole('menu') + .findByRole('menuitem', { name: `Shared by ${user.userId}` }) + .should('be.visible') + }) + }) +}) diff --git a/cypress/e2e/files_trashbin/files-trash-action.cy.ts b/cypress/e2e/files_trashbin/files-trash-action.cy.ts new file mode 100644 index 00000000000..090a7ed8d5d --- /dev/null +++ b/cypress/e2e/files_trashbin/files-trash-action.cy.ts @@ -0,0 +1,69 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { deleteFileWithRequest, triggerFileListAction } from '../files/FilesUtils.ts' + +const FILE_COUNT = 5 +describe('files_trashbin: Empty trashbin action', { testIsolation: true }, () => { + let user: User + + beforeEach(() => { + cy.createRandomUser().then(($user) => { + user = $user + // create 5 fake files and move them to trash + for (let index = 0; index < FILE_COUNT; index++) { + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', `/file${index}.txt`) + deleteFileWithRequest(user, `/file${index}.txt`) + } + // login + cy.login(user) + }) + }) + + it('Can empty trashbin', () => { + cy.visit('/apps/files') + // Home have no files (or the default welcome file) + cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1) + cy.get('[data-cy-files-list-action="empty-trash"]').should('not.exist') + + // Go to trashbin, and see our deleted files + cy.visit('/apps/files/trashbin') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', FILE_COUNT) + + // Empty trashbin + cy.intercept('DELETE', '**/remote.php/dav/trashbin/**').as('emptyTrash') + triggerFileListAction('empty-trash') + + // Confirm dialog + cy.get('[role=dialog]').should('be.visible') + .findByRole('button', { name: 'Empty deleted files' }).click() + + // Wait for the request to finish + cy.wait('@emptyTrash').its('response.statusCode').should('eq', 204) + cy.get('@emptyTrash.all').should('have.length', 1) + + // Trashbin should be empty + cy.get('[data-cy-files-list-row-fileid]').should('not.exist') + }) + + it('Cancelling empty trashbin action does not delete anything', () => { + // Go to trashbin, and see our deleted files + cy.visit('/apps/files/trashbin') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', FILE_COUNT) + + // Empty trashbin + cy.intercept('DELETE', '**/remote.php/dav/trashbin/**').as('emptyTrash') + triggerFileListAction('empty-trash') + + // Cancel dialog + cy.get('[role=dialog]').should('be.visible') + .findByRole('button', { name: 'Cancel' }).click() + + // request was never sent + cy.get('@emptyTrash').should('not.exist') + cy.get('[data-cy-files-list-row-fileid]').should('have.length', FILE_COUNT) + }) + +}) diff --git a/cypress/e2e/files_trashbin/files.cy.ts b/cypress/e2e/files_trashbin/files.cy.ts new file mode 100644 index 00000000000..4c2bce7df7a --- /dev/null +++ b/cypress/e2e/files_trashbin/files.cy.ts @@ -0,0 +1,70 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' + +// @ts-expect-error package has wrong typings +import { deleteDownloadsFolderBeforeEach } from 'cypress-delete-downloads-folder' +import { deleteFileWithRequest, getRowForFileId, selectAllFiles, triggerActionForFileId } from '../files/FilesUtils.ts' + +describe('files_trashbin: download files', { testIsolation: true }, () => { + let user: User + const fileids: number[] = [] + + deleteDownloadsFolderBeforeEach() + + before(() => { + cy.createRandomUser().then(($user) => { + user = $user + + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/file.txt') + .then(({ headers }) => fileids.push(Number.parseInt(headers['oc-fileid']))) + .then(() => deleteFileWithRequest(user, '/file.txt')) + cy.uploadContent(user, new Blob(['<content>']), 'text/plain', '/other-file.txt') + .then(({ headers }) => fileids.push(Number.parseInt(headers['oc-fileid']))) + .then(() => deleteFileWithRequest(user, '/other-file.txt')) + }) + }) + + beforeEach(() => { + cy.login(user) + cy.visit('/apps/files/trashbin') + }) + + it('can download file', () => { + getRowForFileId(fileids[0]).should('be.visible') + getRowForFileId(fileids[1]).should('be.visible') + + triggerActionForFileId(fileids[0], 'download') + + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) + + it('can download a file using default action', () => { + getRowForFileId(fileids[0]) + .should('be.visible') + .findByRole('button', { name: 'Download' }) + .click({ force: true }) + + const downloadsFolder = Cypress.config('downloadsFolder') + cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) + .should('exist') + .and('have.length.gt', 8) + .and('equal', '<content>') + }) + + // TODO: Fix this as this dependens on the webdav zip folder plugin not working for trashbin (and never worked with old NC legacy download ajax as well) + it('does not offer bulk download', () => { + cy.get('[data-cy-files-list-row-checkbox]').should('have.length', 2) + selectAllFiles() + cy.get('.files-list__selected').should('contain.text', '2 selected') + cy.get('[data-cy-files-list-selection-action="restore"]').should('be.visible') + cy.get('[data-cy-files-list-selection-action="download"]').should('not.exist') + }) +}) diff --git a/cypress/e2e/files_versions/filesVersionsUtils.ts b/cypress/e2e/files_versions/filesVersionsUtils.ts index 4ea47162f0b..75c76b7e97c 100644 --- a/cypress/e2e/files_versions/filesVersionsUtils.ts +++ b/cypress/e2e/files_versions/filesVersionsUtils.ts @@ -1,29 +1,10 @@ -/* eslint-disable jsdoc/require-jsdoc */ /** - * @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me> - * - * @author Louis Chemineau <louis@chmn.me> - * - * @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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - +/* eslint-disable jsdoc/require-jsdoc */ import type { User } from '@nextcloud/cypress' -import path from 'path' -import { createShare, type ShareSetting } from '../files_sharing/filesSharingUtils' +import { createShare, type ShareSetting } from '../files_sharing/FilesSharingUtils' export const uploadThreeVersions = (user: User, fileName: string) => { // A new version will not be created if the changes occur diff --git a/cypress/e2e/files_versions/version_creation.cy.ts b/cypress/e2e/files_versions/version_creation.cy.ts index d3650f63939..a0441e96b29 100644 --- a/cypress/e2e/files_versions/version_creation.cy.ts +++ b/cypress/e2e/files_versions/version_creation.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me> - * - * @author Louis Chmn <louis@chmn.me> - * - * @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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils' @@ -47,4 +30,18 @@ describe('Versions creation', () => { cy.get('[data-files-versions-version]').eq(2).contains('Initial version') }) }) + + it('See yourself as version author', () => { + cy.visit('/apps/files') + openVersionsPanel(randomFileName) + + cy.findByRole('tabpanel', { name: 'Versions' }) + .findByRole('list', { name: 'File versions' }) + .findAllByRole('listitem') + .should('have.length', 3) + .first() + .find('[data-cy-files-version-author-name]') + .should('exist') + .and('contain.text', 'You') + }) }) diff --git a/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts b/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts index 9750c2bb20c..8c673b13d4c 100644 --- a/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts +++ b/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2024 Louis Chmn <louis@chmn.me> - * - * @author Louis Chmn <louis@chmn.me> - * - * @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: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { assertVersionContent, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions, nameVersion } from './filesVersionsUtils' diff --git a/cypress/e2e/files_versions/version_deletion.cy.ts b/cypress/e2e/files_versions/version_deletion.cy.ts index 1e90c79fafa..b49aa872639 100644 --- a/cypress/e2e/files_versions/version_deletion.cy.ts +++ b/cypress/e2e/files_versions/version_deletion.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2024 Louis Chmn <louis@chmn.me> - * - * @author Louis Chmn <louis@chmn.me> - * - * @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: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { User } from '@nextcloud/cypress' @@ -76,7 +59,6 @@ describe('Versions restoration', () => { }) it('Does not work without delete permission through direct API access', () => { - let hostname: string let fileId: string|undefined let versionId: string|undefined @@ -85,24 +67,30 @@ describe('Versions restoration', () => { navigateToFolder(folderName) openVersionsPanel(randomFilePath) - cy.url().then(url => { hostname = new URL(url).hostname }) - getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId }) - cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId }) + getRowForFile(randomFileName) + .should('be.visible') + .invoke('attr', 'data-cy-files-list-row-fileid') + .then(($fileId) => { fileId = $fileId }) + cy.get('[data-files-versions-version]') + .eq(1) + .invoke('attr', 'data-files-versions-version') + .then(($versionId) => { versionId = $versionId }) + + cy.logout() cy.then(() => { - cy.logout() - cy.request({ + const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '') + return cy.request({ method: 'DELETE', + url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`, auth: { user: recipient.userId, pass: recipient.password }, headers: { cookie: '', }, - url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`, failOnStatusCode: false, }) - .then(({ status }) => { - expect(status).to.equal(403) - }) + }).then(({ status }) => { + expect(status).to.equal(403) }) }) }) diff --git a/cypress/e2e/files_versions/version_download.cy.ts b/cypress/e2e/files_versions/version_download.cy.ts index 0e4301654f0..548cb86a207 100644 --- a/cypress/e2e/files_versions/version_download.cy.ts +++ b/cypress/e2e/files_versions/version_download.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me> - * - * @author Louis Chmn <louis@chmn.me> - * - * @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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { assertVersionContent, doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils' @@ -31,6 +14,7 @@ describe('Versions download', () => { before(() => { randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt' + cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download') cy.createRandomUser() .then((_user) => { user = _user @@ -41,6 +25,10 @@ describe('Versions download', () => { }) }) + after(() => { + cy.runOccCommand('config:app:delete core shareapi_allow_view_without_download') + }) + it('Download versions and assert their content', () => { assertVersionContent(0, 'v3') assertVersionContent(1, 'v2') @@ -69,31 +57,36 @@ describe('Versions download', () => { }) it('Does not work without download permission through direct API access', () => { - let hostname: string let fileId: string|undefined let versionId: string|undefined setupTestSharedFileFromUser(user, randomFileName, { download: false }) - .then(recipient => { + .then((recipient) => { openVersionsPanel(randomFileName) - cy.url().then(url => { hostname = new URL(url).hostname }) - getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId }) - cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId }) + getRowForFile(randomFileName) + .should('be.visible') + .invoke('attr', 'data-cy-files-list-row-fileid') + .then(($fileId) => { fileId = $fileId }) + + cy.get('[data-files-versions-version]') + .eq(1) + .invoke('attr', 'data-files-versions-version') + .then(($versionId) => { versionId = $versionId }) + cy.logout() cy.then(() => { - cy.logout() - cy.request({ + const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '') + return cy.request({ + url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`, auth: { user: recipient.userId, pass: recipient.password }, headers: { cookie: '', }, - url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`, failOnStatusCode: false, }) - .then(({ status }) => { - expect(status).to.equal(403) - }) + }).then(({ status }) => { + expect(status).to.equal(403) }) }) }) diff --git a/cypress/e2e/files_versions/version_expiration.cy.ts b/cypress/e2e/files_versions/version_expiration.cy.ts index 1c1c6fc70ae..118ac01532f 100644 --- a/cypress/e2e/files_versions/version_expiration.cy.ts +++ b/cypress/e2e/files_versions/version_expiration.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me> - * - * @author Louis Chmn <louis@chmn.me> - * - * @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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { assertVersionContent, nameVersion, openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils' @@ -38,7 +21,7 @@ describe('Versions expiration', () => { }) it('Expire all versions', () => { - cy.runOccCommand('config:system:set versions_retention_obligation --value "0, 0"') + cy.runOccCommand('config:system:set versions_retention_obligation --value \'0, 0\'') cy.runOccCommand('versions:expire') cy.runOccCommand('config:system:set versions_retention_obligation --value auto') cy.visit('/apps/files') @@ -55,7 +38,7 @@ describe('Versions expiration', () => { it('Expire versions v2', () => { nameVersion(2, 'v1') - cy.runOccCommand('config:system:set versions_retention_obligation --value "0, 0"') + cy.runOccCommand('config:system:set versions_retention_obligation --value \'0, 0\'') cy.runOccCommand('versions:expire') cy.runOccCommand('config:system:set versions_retention_obligation --value auto') cy.visit('/apps/files') diff --git a/cypress/e2e/files_versions/version_naming.cy.ts b/cypress/e2e/files_versions/version_naming.cy.ts index a2f0514dfa0..ff299c53227 100644 --- a/cypress/e2e/files_versions/version_naming.cy.ts +++ b/cypress/e2e/files_versions/version_naming.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me> - * - * @author Louis Chmn <louis@chmn.me> - * - * @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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { User } from '@nextcloud/cypress' @@ -86,10 +69,17 @@ describe('Versions naming', () => { }) context('without edit permission', () => { - it('Does not show action', () => { + let recipient: User + + beforeEach(() => { setupTestSharedFileFromUser(user, randomFileName, { update: false }) - openVersionsPanel(randomFileName) + .then(($recipient) => { + recipient = $recipient + openVersionsPanel(randomFileName) + }) + }) + it('Does not show action', () => { cy.get('[data-files-versions-version]').eq(0).find('.action-item__menutoggle').should('not.exist') cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="label"]').should('not.exist') @@ -98,45 +88,45 @@ describe('Versions naming', () => { }) it('Does not work without update permission through direct API access', () => { - let hostname: string let fileId: string|undefined let versionId: string|undefined - setupTestSharedFileFromUser(user, randomFileName, { update: false }) - .then(recipient => { - openVersionsPanel(randomFileName) - - cy.url().then(url => { hostname = new URL(url).hostname }) - getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId }) - cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId }) - - cy.then(() => { - cy.logout() - cy.request({ - method: 'PROPPATCH', - auth: { user: recipient.userId, pass: recipient.password }, - headers: { - cookie: '', - }, - body: `<?xml version="1.0"?> - <d:propertyupdate xmlns:d="DAV:" - xmlns:oc="http://owncloud.org/ns" - xmlns:nc="http://nextcloud.org/ns" - xmlns:ocs="http://open-collaboration-services.org/ns"> - <d:set> - <d:prop> - <nc:version-label>not authorized labeling</nc:version-label> - </d:prop> - </d:set> - </d:propertyupdate>`, - url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`, - failOnStatusCode: false, - }) - .then(({ status }) => { - expect(status).to.equal(403) - }) - }) + getRowForFile(randomFileName) + .should('be.visible') + .invoke('attr', 'data-cy-files-list-row-fileid') + .then(($fileId) => { fileId = $fileId }) + + cy.get('[data-files-versions-version]') + .eq(1) + .invoke('attr', 'data-files-versions-version') + .then(($versionId) => { versionId = $versionId }) + + cy.logout() + cy.then(() => { + const base = Cypress.config('baseUrl')!.replace(/index\.php\/?/, '') + return cy.request({ + method: 'PROPPATCH', + url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`, + auth: { user: recipient.userId, pass: recipient.password }, + headers: { + cookie: '', + }, + body: `<?xml version="1.0"?> + <d:propertyupdate xmlns:d="DAV:" + xmlns:oc="http://owncloud.org/ns" + xmlns:nc="http://nextcloud.org/ns" + xmlns:ocs="http://open-collaboration-services.org/ns"> + <d:set> + <d:prop> + <nc:version-label>not authorized labeling</nc:version-label> + </d:prop> + </d:set> + </d:propertyupdate>`, + failOnStatusCode: false, }) + }).then(({ status }) => { + expect(status).to.equal(403) + }) }) }) }) diff --git a/cypress/e2e/files_versions/version_restoration.cy.ts b/cypress/e2e/files_versions/version_restoration.cy.ts index 72295003bba..34360808f61 100644 --- a/cypress/e2e/files_versions/version_restoration.cy.ts +++ b/cypress/e2e/files_versions/version_restoration.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me> - * - * @author Louis Chmn <louis@chmn.me> - * - * @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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { User } from '@nextcloud/cypress' @@ -94,33 +77,38 @@ describe('Versions restoration', () => { }) it('Does not work without update permission through direct API access', () => { - let hostname: string let fileId: string|undefined let versionId: string|undefined setupTestSharedFileFromUser(user, randomFileName, { update: false }) - .then(recipient => { + .then((recipient) => { openVersionsPanel(randomFileName) - cy.url().then(url => { hostname = new URL(url).hostname }) - getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId }) - cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId }) + getRowForFile(randomFileName) + .should('be.visible') + .invoke('attr', 'data-cy-files-list-row-fileid') + .then(($fileId) => { fileId = $fileId }) + cy.get('[data-files-versions-version]') + .eq(1) + .invoke('attr', 'data-files-versions-version') + .then(($versionId) => { versionId = $versionId }) + + cy.logout() cy.then(() => { - cy.logout() - cy.request({ + const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '') + return cy.request({ method: 'MOVE', + url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`, auth: { user: recipient.userId, pass: recipient.password }, headers: { cookie: '', - Destination: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/restore/target`, + Destination: `${base}}/remote.php/dav/versions/${recipient.userId}/restore/target`, }, - url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`, failOnStatusCode: false, }) - .then(({ status }) => { - expect(status).to.equal(403) - }) + }).then(({ status }) => { + expect(status).to.equal(403) }) }) }) diff --git a/cypress/e2e/files_versions/version_sharing.cy.ts b/cypress/e2e/files_versions/version_sharing.cy.ts new file mode 100644 index 00000000000..e978cb42fd9 --- /dev/null +++ b/cypress/e2e/files_versions/version_sharing.cy.ts @@ -0,0 +1,46 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' +import { navigateToFolder, triggerActionForFile } from '../files/FilesUtils.ts' + +describe('Versions on shares', () => { + const randomSharedFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + const randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt' + const randomFilePath = `${randomSharedFolderName}/${randomFileName}` + let alice: User + let bob: User + + before(() => { + cy.createRandomUser() + .then((user) => { + alice = user + }) + .then(() => { + cy.mkdir(alice, `/${randomSharedFolderName}`) + return setupTestSharedFileFromUser(alice, randomSharedFolderName, {}) + }) + .then((user) => { bob = user }) + .then(() => uploadThreeVersions(alice, randomFilePath)) + }) + + it('See sharees display name as author', () => { + cy.login(bob) + cy.visit('/apps/files') + + navigateToFolder(randomSharedFolderName) + + triggerActionForFile(randomFileName, 'details') + cy.findByRole('tab', { name: 'Versions' }).click() + + cy.findByRole('tabpanel', { name: 'Versions' }) + .findByRole('list', { name: 'File versions' }) + .findAllByRole('listitem') + .first() + .find('[data-cy-files-version-author-name]') + .should('be.visible') + .and('contain.text', alice.userId) + }) +}) diff --git a/cypress/e2e/login/login-redirect.cy.ts b/cypress/e2e/login/login-redirect.cy.ts new file mode 100644 index 00000000000..eb0710dcbcc --- /dev/null +++ b/cypress/e2e/login/login-redirect.cy.ts @@ -0,0 +1,62 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Test that when a session expires / the user logged out in another tab, + * the user gets redirected to the login on the next request. + */ +describe('Logout redirect ', { testIsolation: true }, () => { + + let user + + before(() => { + cy.createRandomUser() + .then(($user) => { + user = $user + }) + }) + + it('Redirects to login if session timed out', () => { + // Login and see settings + cy.login(user) + cy.visit('/settings/user#profile') + cy.findByRole('checkbox', { name: /Enable profile/i }) + .should('exist') + + // clear session + cy.clearAllCookies() + + // trigger an request + cy.findByRole('checkbox', { name: /Enable profile/i }) + .click({ force: true }) + + // See that we are redirected + cy.url() + .should('match', /\/login/i) + .and('include', `?redirect_url=${encodeURIComponent('/index.php/settings/user#profile')}`) + + cy.get('form[name="login"]').should('be.visible') + }) + + it('Redirect from login works', () => { + cy.logout() + // visit the login + cy.visit(`/login?redirect_url=${encodeURIComponent('/index.php/settings/user#profile')}`) + + // see login + cy.get('form[name="login"]').should('be.visible') + cy.get('form[name="login"]').within(() => { + cy.get('input[name="user"]').type(user.userId) + cy.get('input[name="password"]').type(user.password) + cy.contains('button[data-login-form-submit]', 'Log in').click() + }) + + // see that we are correctly redirected + cy.url().should('include', '/index.php/settings/user#profile') + cy.findByRole('checkbox', { name: /Enable profile/i }) + .should('exist') + }) + +}) diff --git a/cypress/e2e/login/login.cy.ts b/cypress/e2e/login/login.cy.ts index 39c9e213039..97e3b9a24bf 100644 --- a/cypress/e2e/login/login.cy.ts +++ b/cypress/e2e/login/login.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { User } from '@nextcloud/cypress' diff --git a/cypress/e2e/login/webauth.cy.ts b/cypress/e2e/login/webauth.cy.ts new file mode 100644 index 00000000000..fb67ed7f21c --- /dev/null +++ b/cypress/e2e/login/webauth.cy.ts @@ -0,0 +1,152 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' + +interface IChromeVirtualAuthenticator { + authenticatorId: string +} + +/** + * Create a virtual authenticator using chrome debug protocol + */ +async function createAuthenticator(): Promise<IChromeVirtualAuthenticator> { + await Cypress.automation('remote:debugger:protocol', { + command: 'WebAuthn.enable', + }) + const authenticator = await Cypress.automation('remote:debugger:protocol', { + command: 'WebAuthn.addVirtualAuthenticator', + params: { + options: { + protocol: 'ctap2', + ctap2Version: 'ctap2_1', + hasUserVerification: true, + transport: 'usb', + automaticPresenceSimulation: true, + isUserVerified: true, + }, + }, + }) + return authenticator +} + +/** + * Delete a virtual authenticator using chrome devbug protocol + * + * @param authenticator the authenticator object + */ +async function deleteAuthenticator(authenticator: IChromeVirtualAuthenticator) { + await Cypress.automation('remote:debugger:protocol', { + command: 'WebAuthn.removeVirtualAuthenticator', + params: { + ...authenticator, + }, + }) +} + +describe('Login using WebAuthn', () => { + let authenticator: IChromeVirtualAuthenticator + let user: User + + afterEach(() => { + cy.deleteUser(user) + .then(() => deleteAuthenticator(authenticator)) + }) + + beforeEach(() => { + cy.createRandomUser() + .then(($user) => { + user = $user + cy.login(user) + }) + .then(() => createAuthenticator()) + .then(($authenticator) => { + authenticator = $authenticator + cy.log('Created virtual authenticator') + }) + }) + + it('add and delete WebAuthn', () => { + cy.intercept('**/settings/api/personal/webauthn/registration').as('webauthn') + cy.visit('/settings/user/security') + + cy.contains('[role="note"]', /No devices configured/i).should('be.visible') + + cy.findByRole('button', { name: /Add WebAuthn device/i }) + .should('be.visible') + .click() + + cy.wait('@webauthn') + + cy.findByRole('textbox', { name: /Device name/i }) + .should('be.visible') + .type('test device{enter}') + + cy.wait('@webauthn') + + cy.contains('[role="note"]', /No devices configured/i).should('not.exist') + + cy.findByRole('list', { name: /following devices are configured for your account/i }) + .should('be.visible') + .contains('li', 'test device') + .should('be.visible') + .findByRole('button', { name: /Actions/i }) + .click() + + cy.findByRole('menuitem', { name: /Delete/i }) + .should('be.visible') + .click() + + cy.contains('[role="note"]', /No devices configured/i).should('be.visible') + cy.findByRole('list', { name: /following devices are configured for your account/i }) + .should('not.exist') + + cy.reload() + cy.contains('[role="note"]', /No devices configured/i).should('be.visible') + }) + + it('add WebAuthn and login', () => { + cy.intercept('GET', '**/settings/api/personal/webauthn/registration').as('webauthnSetupInit') + cy.intercept('POST', '**/settings/api/personal/webauthn/registration').as('webauthnSetupDone') + cy.intercept('POST', '**/login/webauthn/start').as('webauthnLogin') + + cy.visit('/settings/user/security') + + cy.findByRole('button', { name: /Add WebAuthn device/i }) + .should('be.visible') + .click() + cy.wait('@webauthnSetupInit') + + cy.findByRole('textbox', { name: /Device name/i }) + .should('be.visible') + .type('test device{enter}') + cy.wait('@webauthnSetupDone') + + cy.findByRole('list', { name: /following devices are configured for your account/i }) + .should('be.visible') + .findByText('test device') + .should('be.visible') + + cy.logout() + cy.visit('/login') + + cy.findByRole('button', { name: /Log in with a device/i }) + .should('be.visible') + .click() + + cy.findByRole('form', { name: /Log in with a device/i }) + .should('be.visible') + .findByRole('textbox', { name: /Login or email/i }) + .should('be.visible') + .type(`{selectAll}${user.userId}`) + + cy.findByRole('button', { name: /Log in/i }) + .click() + cy.wait('@webauthnLogin') + + // Then I see that the current page is the Files app + cy.url().should('match', /apps\/dashboard(\/|$)/) + }) +}) diff --git a/cypress/e2e/settings/access-levels.cy.ts b/cypress/e2e/settings/access-levels.cy.ts index ac02d607de2..4bf0cbc1832 100644 --- a/cypress/e2e/settings/access-levels.cy.ts +++ b/cypress/e2e/settings/access-levels.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { User } from '@nextcloud/cypress' @@ -40,7 +23,9 @@ describe('Settings: Ensure only administrator can see the administration setting // I open the settings menu getNextcloudUserMenuToggle().click() // I navigate to the settings panel - getNextcloudUserMenu().find('#settings a').click() + getNextcloudUserMenu() + .findByRole('link', { name: /settings/i }) + .click() cy.url().should('match', /\/settings\/user$/) cy.get('#app-navigation').should('be.visible').within(() => { @@ -62,7 +47,9 @@ describe('Settings: Ensure only administrator can see the administration setting // I open the settings menu getNextcloudUserMenuToggle().click() // I navigate to the settings panel - getNextcloudUserMenu().find('#settings a').click() + getNextcloudUserMenu() + .findByRole('link', { name: /Personal settings/i }) + .click() cy.url().should('match', /\/settings\/user$/) cy.get('#app-navigation').should('be.visible').within(() => { diff --git a/cypress/e2e/settings/apps.cy.ts b/cypress/e2e/settings/apps.cy.ts index c1ef24951a9..0df073271ef 100644 --- a/cypress/e2e/settings/apps.cy.ts +++ b/cypress/e2e/settings/apps.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { User } from '@nextcloud/cypress' @@ -34,8 +17,15 @@ describe('Settings: App management', { testIsolation: true }, () => { // I am logged in as the admin cy.login(admin) + + // Intercept the apps list request + cy.intercept('GET', '*/settings/apps/list').as('fetchAppsList') + // I open the Apps management cy.visit('/settings/apps/installed') + + // Wait for the apps list to load + cy.wait('@fetchAppsList') }) it('Can enable an installed app', () => { @@ -133,7 +123,7 @@ describe('Settings: App management', { testIsolation: true }, () => { cy.get('#app-category-your-bundles').find('.active').should('exist') // I see the app bundles cy.get('#apps-list').contains('tr', 'Enterprise bundle') - cy.get('#apps-list').contains('tr', 'Education Edition') + cy.get('#apps-list').contains('tr', 'Education bundle') // I see that the "Enterprise bundle" is disabled cy.get('#apps-list').contains('tr', 'Enterprise bundle').contains('button', 'Download and enable all') }) diff --git a/cypress/e2e/settings/personal-info.cy.ts b/cypress/e2e/settings/personal-info.cy.ts index a7564d5125e..8d4b4bb606a 100644 --- a/cypress/e2e/settings/personal-info.cy.ts +++ b/cypress/e2e/settings/personal-info.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { User } from '@nextcloud/cypress' @@ -115,33 +98,55 @@ const checkSettingsVisibility = (property: string, defaultVisibility: Visibility }) */ } -const genericProperties = ['Location', 'X (formerly Twitter)', 'Fediverse'] +const genericProperties = [ + ['Location', 'Berlin'], + ['X (formerly Twitter)', 'nextclouders'], + ['Fediverse', 'nextcloud@mastodon.xyz'], +] const nonfederatedProperties = ['Organisation', 'Role', 'Headline', 'About'] describe('Settings: Change personal information', { testIsolation: true }, () => { + let snapshot: string = '' before(() => { + // make sure the fediverse check does not do http requests + cy.runOccCommand('config:system:set has_internet_connection --type bool --value false') // ensure we can set locale and language cy.runOccCommand('config:system:delete force_language') cy.runOccCommand('config:system:delete force_locale') + cy.createRandomUser().then(($user) => { + user = $user + cy.modifyUser(user, 'language', 'en') + cy.modifyUser(user, 'locale', 'en_US') + + // Make sure the user is logged in at least once + // before the snapshot is taken to speed up the tests + cy.login(user) + cy.visit('/settings/user') + + cy.saveState().then(($snapshot) => { + snapshot = $snapshot + }) + }) }) after(() => { + cy.runOccCommand('config:system:delete has_internet_connection') + cy.runOccCommand('config:system:set force_language --value en') cy.runOccCommand('config:system:set force_locale --value en_US') }) beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - cy.modifyUser(user, 'language', 'en') - cy.modifyUser(user, 'locale', 'en_US') - cy.login($user) - cy.visit('/settings/user') - }) + cy.login(user) + cy.visit('/settings/user') cy.intercept('PUT', /ocs\/v2.php\/cloud\/users\//).as('submitSetting') }) + afterEach(() => { + cy.restoreState(snapshot) + }) + it('Can dis- and enable the profile', () => { cy.visit(`/u/${user.userId}`) cy.contains('h2', user.userId).should('be.visible') @@ -149,6 +154,7 @@ describe('Settings: Change personal information', { testIsolation: true }, () => cy.visit('/settings/user') cy.contains('Enable profile').click() handlePasswordConfirmation(user.password) + cy.wait('@submitSetting') cy.visit(`/u/${user.userId}`, { failOnStatusCode: false }) cy.contains('h2', 'Profile not found').should('be.visible') @@ -156,6 +162,7 @@ describe('Settings: Change personal information', { testIsolation: true }, () => cy.visit('/settings/user') cy.contains('Enable profile').click() handlePasswordConfirmation(user.password) + cy.wait('@submitSetting') cy.visit(`/u/${user.userId}`, { failOnStatusCode: false }) cy.contains('h2', user.userId).should('be.visible') @@ -329,6 +336,57 @@ describe('Settings: Change personal information', { testIsolation: true }, () => cy.get('a[href="tel:+498972101099701"]').should('be.visible') }) + it('Can set phone number with phone region', () => { + cy.contains('label', 'Phone number').scrollIntoView() + inputForLabel('Phone number').type('{selectAll}0 40 428990') + inputForLabel('Phone number').should('have.attr', 'class').and('contain', '--error') + + cy.runOccCommand('config:system:set default_phone_region --value DE') + cy.reload() + + cy.contains('label', 'Phone number').scrollIntoView() + inputForLabel('Phone number').type('{selectAll}0 40 428990') + handlePasswordConfirmation(user.password) + + cy.wait('@submitSetting') + cy.reload() + inputForLabel('Phone number').should('have.value', '+4940428990') + }) + + it('Can reset phone number', () => { + cy.contains('label', 'Phone number').scrollIntoView() + inputForLabel('Phone number').type('{selectAll}+49 40 428990') + handlePasswordConfirmation(user.password) + + cy.wait('@submitSetting') + cy.reload() + inputForLabel('Phone number').should('have.value', '+4940428990') + + inputForLabel('Phone number').clear() + handlePasswordConfirmation(user.password) + + cy.wait('@submitSetting') + cy.reload() + inputForLabel('Phone number').should('have.value', '') + }) + + it('Can reset social media property', () => { + cy.contains('label', 'Fediverse').scrollIntoView() + inputForLabel('Fediverse').type('{selectAll}@nextcloud@mastodon.social') + handlePasswordConfirmation(user.password) + + cy.wait('@submitSetting') + cy.reload() + inputForLabel('Fediverse').should('have.value', 'nextcloud@mastodon.social') + + inputForLabel('Fediverse').clear() + handlePasswordConfirmation(user.password) + + cy.wait('@submitSetting') + cy.reload() + inputForLabel('Fediverse').should('have.value', '') + }) + it('Can set Website and change its visibility', () => { cy.contains('label', 'Website').scrollIntoView() // Check invalid input @@ -350,22 +408,21 @@ describe('Settings: Change personal information', { testIsolation: true }, () => }) // Check generic properties that allow any visibility and any value - genericProperties.forEach((property) => { + genericProperties.forEach(([property, value]) => { it(`Can set ${property} and change its visibility`, () => { - const uniqueValue = `${property.toUpperCase()} ${property.toLowerCase()}` cy.contains('label', property).scrollIntoView() - inputForLabel(property).type(uniqueValue) + inputForLabel(property).type(value) handlePasswordConfirmation(user.password) cy.wait('@submitSetting') cy.reload() - inputForLabel(property).should('have.value', uniqueValue) + inputForLabel(property).should('have.value', value) checkSettingsVisibility(property) // check it is visible on the profile cy.visit(`/u/${user.userId}`) - cy.contains(uniqueValue).should('be.visible') + cy.contains(value).should('be.visible') }) }) diff --git a/cypress/e2e/settings/users-group-admin.cy.ts b/cypress/e2e/settings/users-group-admin.cy.ts new file mode 100644 index 00000000000..5b5dcfd33a8 --- /dev/null +++ b/cypress/e2e/settings/users-group-admin.cy.ts @@ -0,0 +1,186 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +/// <reference types="cypress-if" /> +import { User } from '@nextcloud/cypress' +import { getUserListRow, handlePasswordConfirmation } from './usersUtils' +// eslint-disable-next-line n/no-extraneous-import +import randomString from 'crypto-random-string' + +const admin = new User('admin', 'admin') +const john = new User('john', '123456') + +/** + * Make a user subadmin of a group. + * + * @param user - The user to make subadmin + * @param group - The group the user should be subadmin of + */ +function makeSubAdmin(user: User, group: string): void { + cy.request({ + url: `${Cypress.config('baseUrl')!.replace('/index.php', '')}/ocs/v2.php/cloud/users/${user.userId}/subadmins`, + method: 'POST', + auth: { + user: admin.userId, + password: admin.userId, + }, + headers: { + 'OCS-ApiRequest': 'true', + }, + body: { + groupid: group, + }, + }) +} + +describe('Settings: Create accounts as a group admin', function() { + + let subadmin: User + let group: string + + beforeEach(() => { + group = randomString(7) + cy.deleteUser(john) + cy.createRandomUser().then((user) => { + subadmin = user + cy.runOccCommand(`group:add '${group}'`) + cy.runOccCommand(`group:adduser '${group}' '${subadmin.userId}'`) + makeSubAdmin(subadmin, group) + }) + }) + + it('Can create a user with prefilled single group', () => { + cy.login(subadmin) + // open the User settings + cy.visit('/settings/users') + + // open the New user modal + cy.get('button#new-user-button').click() + + cy.get('form[data-test="form"]').within(() => { + // see that the correct group is preselected + cy.contains('[data-test="groups"] .vs__selected', group).should('be.visible') + // see that the username is "" + cy.get('input[data-test="username"]').should('exist').and('have.value', '') + // set the username to john + cy.get('input[data-test="username"]').type(john.userId) + // see that the username is john + cy.get('input[data-test="username"]').should('have.value', john.userId) + // see that the password is "" + cy.get('input[type="password"]').should('exist').and('have.value', '') + // set the password to 123456 + cy.get('input[type="password"]').type(john.password) + // see that the password is 123456 + cy.get('input[type="password"]').should('have.value', john.password) + }) + + cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => { + // submit the new user form + cy.get('button[type="submit"]').click({ force: true }) + }) + + // Make sure no confirmation modal is shown + handlePasswordConfirmation(admin.password) + + // see that the created user is in the list + getUserListRow(john.userId) + // see that the list of users contains the user john + .contains(john.userId).should('exist') + }) + + it('Can create a new user when member of multiple groups', () => { + const group2 = randomString(7) + cy.runOccCommand(`group:add '${group2}'`) + cy.runOccCommand(`group:adduser '${group2}' '${subadmin.userId}'`) + makeSubAdmin(subadmin, group2) + + cy.login(subadmin) + // open the User settings + cy.visit('/settings/users') + + // open the New user modal + cy.get('button#new-user-button').click() + + cy.get('form[data-test="form"]').within(() => { + // see that no group is pre-selected + cy.get('[data-test="groups"] .vs__selected').should('not.exist') + // see both groups are available + cy.findByRole('combobox', { name: /member of the following groups/i }) + .should('be.visible') + .click() + // can select both groups + cy.document().its('body') + .findByRole('listbox', { name: 'Options' }) + .should('be.visible') + .as('options') + .findAllByRole('option') + .should('have.length', 2) + .get('@options') + .findByRole('option', { name: group }) + .should('be.visible') + .get('@options') + .findByRole('option', { name: group2 }) + .should('be.visible') + .click() + // see group is selected + cy.contains('[data-test="groups"] .vs__selected', group2).should('be.visible') + + // see that the username is "" + cy.get('input[data-test="username"]').should('exist').and('have.value', '') + // set the username to john + cy.get('input[data-test="username"]').type(john.userId) + // see that the username is john + cy.get('input[data-test="username"]').should('have.value', john.userId) + // see that the password is "" + cy.get('input[type="password"]').should('exist').and('have.value', '') + // set the password to 123456 + cy.get('input[type="password"]').type(john.password) + // see that the password is 123456 + cy.get('input[type="password"]').should('have.value', john.password) + }) + + cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => { + // submit the new user form + cy.get('button[type="submit"]').click({ force: true }) + }) + + // Make sure no confirmation modal is shown + handlePasswordConfirmation(admin.password) + + // see that the created user is in the list + getUserListRow(john.userId) + // see that the list of users contains the user john + .contains(john.userId).should('exist') + }) + + it('Only sees groups they are subadmin of', () => { + const group2 = randomString(7) + cy.runOccCommand(`group:add '${group2}'`) + cy.runOccCommand(`group:adduser '${group2}' '${subadmin.userId}'`) + // not a subadmin! + + cy.login(subadmin) + // open the User settings + cy.visit('/settings/users') + + // open the New user modal + cy.get('button#new-user-button').click() + + cy.get('form[data-test="form"]').within(() => { + // see that the subadmin group is pre-selected + cy.contains('[data-test="groups"] .vs__selected', group).should('be.visible') + // see only the subadmin group is available + cy.findByRole('combobox', { name: /member of the following groups/i }) + .should('be.visible') + .click() + // can select both groups + cy.document().its('body') + .findByRole('listbox', { name: 'Options' }) + .should('be.visible') + .as('options') + .findAllByRole('option') + .should('have.length', 1) + }) + }) +}) diff --git a/cypress/e2e/settings/users.cy.ts b/cypress/e2e/settings/users.cy.ts index c90afc8866e..5b8726e92ca 100644 --- a/cypress/e2e/settings/users.cy.ts +++ b/cypress/e2e/settings/users.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ /// <reference types="cypress-if" /> import { User } from '@nextcloud/cypress' @@ -56,6 +39,9 @@ describe('Settings: Create and delete accounts', function() { cy.get('input[type="password"]').type(john.password) // see that the password is 123456 cy.get('input[type="password"]').should('have.value', john.password) + }) + + cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => { // submit the new user form cy.get('button[type="submit"]').click({ force: true }) }) @@ -90,6 +76,9 @@ describe('Settings: Create and delete accounts', function() { cy.get('input[type="password"]').should('exist').and('have.value', '') cy.get('input[type="password"]').type(john.password) cy.get('input[type="password"]').should('have.value', john.password) + }) + + cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => { // submit the new user form cy.get('button[type="submit"]').click({ force: true }) }) @@ -126,12 +115,13 @@ describe('Settings: Create and delete accounts', function() { // The "Delete account" action in the actions menu is shown and clicked cy.get('.action-item__popper .action').contains('Delete account').should('exist').click({ force: true }) - // And confirmation dialog accepted - cy.get('.nc-generic-dialog button').contains(`Delete ${testUser.userId}`).click({ force: true }) // Make sure no confirmation modal is shown handlePasswordConfirmation(admin.password) + // And confirmation dialog accepted + cy.get('.nc-generic-dialog button').contains(`Delete ${testUser.userId}`).click({ force: true }) + // deleted clicked the user is not shown anymore getUserListRow(testUser.userId).should('not.exist') }) diff --git a/cypress/e2e/settings/usersUtils.ts b/cypress/e2e/settings/usersUtils.ts index 56eff5e7d7d..7d8ea55d35b 100644 --- a/cypress/e2e/settings/usersUtils.ts +++ b/cypress/e2e/settings/usersUtils.ts @@ -1,23 +1,6 @@ /** - * @copyright 2023 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { User } from '@nextcloud/cypress' diff --git a/cypress/e2e/settings/users_columns.cy.ts b/cypress/e2e/settings/users_columns.cy.ts index 5f2a293b824..0afbf14e773 100644 --- a/cypress/e2e/settings/users_columns.cy.ts +++ b/cypress/e2e/settings/users_columns.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright 2023 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { User } from '@nextcloud/cypress' diff --git a/cypress/e2e/settings/users_disable.cy.ts b/cypress/e2e/settings/users_disable.cy.ts index dd555c64f91..6195d43e211 100644 --- a/cypress/e2e/settings/users_disable.cy.ts +++ b/cypress/e2e/settings/users_disable.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { User } from '@nextcloud/cypress' diff --git a/cypress/e2e/settings/users_groups.cy.ts b/cypress/e2e/settings/users_groups.cy.ts index fd56c558b4f..8d84ddc6bb4 100644 --- a/cypress/e2e/settings/users_groups.cy.ts +++ b/cypress/e2e/settings/users_groups.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright 2023 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { User } from '@nextcloud/cypress' @@ -72,15 +55,17 @@ describe('Settings: Assign user to a group', { testIsolation: false }, () => { }) cy.runOccCommand(`group:add '${groupName}'`) cy.login(admin) + cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups') cy.visit('/settings/users') + cy.wait('@loadGroups') }) it('see that the group is in the list', () => { - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').contains('li', groupName).should('exist') - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').contains('li', groupName).within(() => { - cy.get('.counter-bubble__counter') - .should('not.exist') // is hidden when 0 - }) + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) + .should('exist') + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) + .find('.counter-bubble__counter') + .should('not.exist') // is hidden when 0 }) it('see that the user is in the list', () => { @@ -118,8 +103,7 @@ describe('Settings: Assign user to a group', { testIsolation: false }, () => { it('see the group was successfully assigned', () => { // see a new memeber - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]') - .contains('li', groupName) + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) .find('.counter-bubble__counter') .should('contain', '1') }) @@ -138,23 +122,25 @@ describe('Settings: Delete an empty group', { testIsolation: false }, () => { before(() => { cy.runOccCommand(`group:add '${groupName}'`) cy.login(admin) + cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups') cy.visit('/settings/users') + cy.wait('@loadGroups') }) it('see that the group is in the list', () => { - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => { - // see that the list of groups contains the group foo - cy.contains(groupName).should('exist').scrollIntoView() - // open the actions menu for the group - cy.contains('li', groupName).within(() => { - cy.get('button.action-item__menutoggle').click({ force: true }) - }) - }) + // see that the list of groups contains the group foo + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) + .should('exist') + .scrollIntoView() + // open the actions menu for the group + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) + .find('button.action-item__menutoggle') + .click({ force: true }) }) it('can delete the group', () => { - // The "Remove group" action in the actions menu is shown and clicked - cy.get('.action-item__popper button').contains('Remove group').should('exist').click({ force: true }) + // The "Delete group" action in the actions menu is shown and clicked + cy.get('.action-item__popper button').contains('Delete group').should('exist').click({ force: true }) // And confirmation dialog accepted cy.get('.modal-container button').contains('Confirm').click({ force: true }) @@ -163,10 +149,9 @@ describe('Settings: Delete an empty group', { testIsolation: false }, () => { }) it('deleted group is not shown anymore', () => { - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => { - // see that the list of groups does not contain the group - cy.contains(groupName).should('not.exist') - }) + // see that the list of groups does not contain the group + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) + .should('not.exist') // and also not in database cy.runOccCommand('group:list --output=json').then(($response) => { const groups: string[] = Object.keys(JSON.parse($response.stdout)) @@ -186,24 +171,27 @@ describe('Settings: Delete a non empty group', () => { cy.runOccCommand(`group:addUser '${groupName}' '${$user.userId}'`) }) cy.login(admin) + cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups') cy.visit('/settings/users') + cy.wait('@loadGroups') }) after(() => cy.deleteUser(testUser)) it('see that the group is in the list', () => { // see that the list of groups contains the group - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').contains('li', groupName).should('exist').scrollIntoView() + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) + .should('exist') + .scrollIntoView() }) it('can delete the group', () => { // open the menu - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]') - .contains('li', groupName) + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) .find('button.action-item__menutoggle') .click({ force: true }) - // The "Remove group" action in the actions menu is shown and clicked - cy.get('.action-item__popper button').contains('Remove group').should('exist').click({ force: true }) + // The "Delete group" action in the actions menu is shown and clicked + cy.get('.action-item__popper button').contains('Delete group').should('exist').click({ force: true }) // And confirmation dialog accepted cy.get('.modal-container button').contains('Confirm').click({ force: true }) @@ -212,10 +200,9 @@ describe('Settings: Delete a non empty group', () => { }) it('deleted group is not shown anymore', () => { - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => { - // see that the list of groups does not contain the group foo - cy.contains(groupName).should('not.exist') - }) + // see that the list of groups does not contain the group foo + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) + .should('not.exist') // and also not in database cy.runOccCommand('group:list --output=json').then(($response) => { const groups: string[] = Object.keys(JSON.parse($response.stdout)) @@ -224,13 +211,13 @@ describe('Settings: Delete a non empty group', () => { }) }) -describe.only('Settings: Sort groups in the UI', () => { +describe('Settings: Sort groups in the UI', () => { before(() => { // Clear state cy.runOccCommand('group:list --output json').then((output) => { const groups = Object.keys(JSON.parse(output.stdout)).filter((group) => group !== 'admin') groups.forEach((group) => { - cy.runOccCommand(`group:delete "${group}"`) + cy.runOccCommand(`group:delete '${group}'`) }) }) @@ -238,7 +225,7 @@ describe.only('Settings: Sort groups in the UI', () => { cy.runOccCommand('group:add A') cy.runOccCommand('group:add B') cy.createRandomUser().then((user) => { - cy.runOccCommand(`group:adduser B "${user.userId}"`) + cy.runOccCommand(`group:adduser B '${user.userId}'`) }) // Visit the settings as admin diff --git a/cypress/e2e/settings/users_manager.cy.ts b/cypress/e2e/settings/users_manager.cy.ts new file mode 100644 index 00000000000..b7596ddf0ce --- /dev/null +++ b/cypress/e2e/settings/users_manager.cy.ts @@ -0,0 +1,121 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' +import { getUserListRow, handlePasswordConfirmation, toggleEditButton, waitLoading } from './usersUtils' +import { clearState } from '../../support/commonUtils' + +const admin = new User('admin', 'admin') + +describe('Settings: User Manager Management', function() { + let user: User + let manager: User + + beforeEach(function() { + clearState() + cy.createRandomUser().then(($user) => { + manager = $user + return cy.createRandomUser() + }).then(($user) => { + user = $user + cy.login(admin) + cy.intercept('PUT', `/ocs/v2.php/cloud/users/${user.userId}*`).as('updateUser') + }) + }) + + it('Can assign and remove a manager through the UI', function() { + cy.visit('/settings/users') + + toggleEditButton(user, true) + + // Scroll to manager cell and wait for it to be visible + getUserListRow(user.userId) + .find('[data-cy-user-list-cell-manager]') + .scrollIntoView() + .should('be.visible') + + // Assign a manager + getUserListRow(user.userId).find('[data-cy-user-list-cell-manager]').within(() => { + // Verify no manager is set initially + cy.get('.vs__selected').should('not.exist') + + // Open the dropdown menu + cy.get('[role="combobox"]').click({ force: true }) + + // Wait for the dropdown to be visible and initialized + waitLoading('[data-cy-user-list-input-manager]') + + // Type the manager's username to search + cy.get('input[type="search"]').type(manager.userId, { force: true }) + + // Wait for the search results to load + waitLoading('[data-cy-user-list-input-manager]') + }) + + // Now select the manager from the filtered results + // Since the dropdown is floating, we need to search globally + cy.get('.vs__dropdown-menu').find('li').contains('span', manager.userId).should('be.visible').click({ force: true }) + + // Handle password confirmation if needed + handlePasswordConfirmation(admin.password) + + // Verify the manager is selected in the UI + cy.get('.vs__selected').should('exist').and('contain.text', manager.userId) + + // Verify the PUT request was made to set the manager + cy.wait('@updateUser').then((interception) => { + // Verify the request URL and body + expect(interception.request.url).to.match(/\/cloud\/users\/.+/) + expect(interception.request.body).to.deep.equal({ + key: 'manager', + value: manager.userId + }) + expect(interception.response?.statusCode).to.equal(200) + }) + + // Wait for the save to complete + waitLoading('[data-cy-user-list-input-manager]') + + // Verify the manager is set in the backend + cy.getUserData(user).then(($result) => { + expect($result.body).to.contain(`<manager>${manager.userId}</manager>`) + }) + + // Now remove the manager + getUserListRow(user.userId).find('[data-cy-user-list-cell-manager]').within(() => { + // Clear the manager selection + cy.get('.vs__clear').click({ force: true }) + + // Verify the manager is cleared in the UI + cy.get('.vs__selected').should('not.exist') + + // Handle password confirmation if needed + handlePasswordConfirmation(admin.password) + }) + + // Verify the PUT request was made to clear the manager + cy.wait('@updateUser').then((interception) => { + // Verify the request URL and body + expect(interception.request.url).to.match(/\/cloud\/users\/.+/) + expect(interception.request.body).to.deep.equal({ + key: 'manager', + value: '', + }) + expect(interception.response?.statusCode).to.equal(200) + }) + + // Wait for the save to complete + waitLoading('[data-cy-user-list-input-manager]') + + // Verify the manager is cleared in the backend + cy.getUserData(user).then(($result) => { + expect($result.body).to.not.contain(`<manager>${manager.userId}</manager>`) + expect($result.body).to.contain('<manager></manager>') + }) + + // Finish editing the user + toggleEditButton(user, false) + }) +}) diff --git a/cypress/e2e/settings/users_modify.cy.ts b/cypress/e2e/settings/users_modify.cy.ts index b230fb998a5..749bded2e94 100644 --- a/cypress/e2e/settings/users_modify.cy.ts +++ b/cypress/e2e/settings/users_modify.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { User } from '@nextcloud/cypress' @@ -198,47 +181,6 @@ describe('Settings: Change user properties', function() { }) }) - it('Can set manager of a user', function() { - // create the manager - let manager: User - cy.createRandomUser().then(($user) => { manager = $user }) - - // open the User settings as admin - cy.login(admin) - cy.visit('/settings/users') - - // toggle edit button into edit mode - toggleEditButton(user, true) - - getUserListRow(user.userId) - .find('[data-cy-user-list-cell-manager]') - .scrollIntoView() - - getUserListRow(user.userId).find('[data-cy-user-list-cell-manager]').within(() => { - // see that the user has no manager - cy.get('.vs__selected').should('not.exist') - // Open the dropdown menu - cy.get('[role="combobox"]').click({ force: true }) - // select the manager - cy.contains('li', manager.userId).click({ force: true }) - - // Handle password confirmation on time out - handlePasswordConfirmation(admin.password) - - // see that the user has a manager set - cy.get('.vs__selected').should('exist').and('contain.text', manager.userId) - }) - - // see that the changes are loading - waitLoading('[data-cy-user-list-input-manager]') - - // finish editing the user - toggleEditButton(user, false) - - // validate the manager is set - cy.getUserData(user).then(($result) => expect($result.body).to.contain(`<manager>${manager.userId}</manager>`)) - }) - it('Can make user a subadmin of a group', function() { // create a group const groupName = 'userstestgroup' @@ -256,6 +198,8 @@ describe('Settings: Change user properties', function() { cy.get('.vs__selected').should('not.exist') // Open the dropdown menu cy.get('[role="combobox"]').click({ force: true }) + // Search for the group + cy.get('[role="combobox"]').type('userstestgroup') // select the group cy.contains('li', groupName).click({ force: true }) diff --git a/cypress/e2e/systemtags/admin-settings.cy.ts b/cypress/e2e/systemtags/admin-settings.cy.ts index 3c9a8b25cf4..ac85cf34d65 100644 --- a/cypress/e2e/systemtags/admin-settings.cy.ts +++ b/cypress/e2e/systemtags/admin-settings.cy.ts @@ -1,23 +1,6 @@ /** - * @copyright 2023 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { User } from '@nextcloud/cypress' diff --git a/cypress/e2e/systemtags/files-bulk-action.cy.ts b/cypress/e2e/systemtags/files-bulk-action.cy.ts new file mode 100644 index 00000000000..7ed9ad7fa7b --- /dev/null +++ b/cypress/e2e/systemtags/files-bulk-action.cy.ts @@ -0,0 +1,468 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { randomBytes } from 'crypto' +import { getRowForFile, selectAllFiles, selectRowForFile, triggerSelectionAction } from '../files/FilesUtils' +import { createShare } from '../files_sharing/FilesSharingUtils' + +let tags = {} as Record<string, number> +const files = [ + 'file1.txt', + 'file2.txt', + 'file3.txt', + 'file4.txt', + 'file5.txt', +] + +function resetTags() { + tags = {} + for (const tag in [0, 1, 2, 3, 4]) { + tags[randomBytes(8).toString('base64').slice(0, 6)] = 0 + } + + // delete any existing tags + cy.runOccCommand('tag:list --output=json').then((output) => { + Object.keys(JSON.parse(output.stdout)).forEach((id) => { + cy.runOccCommand(`tag:delete ${id}`) + }) + }) + + // create tags + Object.keys(tags).forEach((tag) => { + cy.runOccCommand(`tag:add ${tag} public --output=json`).then((output) => { + tags[tag] = JSON.parse(output.stdout).id as number + }) + }) + cy.log('Using tags', tags) +} + +function expectInlineTagForFile(file: string, tags: string[]) { + getRowForFile(file) + .find('[data-systemtags-fileid]') + .findAllByRole('listitem') + .should('have.length', tags.length) + .each(tag => { + expect(tag.text()).to.be.oneOf(tags) + }) +} + +function triggerTagManagementDialogAction() { + cy.intercept('PROPFIND', '/remote.php/dav/systemtags/').as('getTagsList') + triggerSelectionAction('systemtags:bulk') + cy.wait('@getTagsList') + cy.get('[data-cy-systemtags-picker]').should('be.visible') +} + +describe('Systemtags: Files bulk action', { testIsolation: false }, () => { + let user1: User + let user2: User + + before(() => { + cy.createRandomUser().then((_user1) => { + user1 = _user1 + cy.createRandomUser().then((_user2) => { + user2 = _user2 + }) + + files.forEach((file) => { + cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file) + }) + }) + + resetTags() + }) + + after(() => { + resetTags() + cy.runOccCommand('config:app:set systemtags restrict_creation_to_admin --value 0') + }) + + it('Can assign tag to selection', () => { + cy.login(user1) + cy.visit('/apps/files') + + files.forEach((file) => { + getRowForFile(file).should('be.visible') + }) + selectRowForFile('file2.txt') + selectRowForFile('file4.txt') + + triggerTagManagementDialogAction() + cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) + cy.get('[data-cy-systemtags-picker-tag-color]').should('have.length', 5) + + cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') + cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') + + const tag = Object.keys(tags)[3] + cy.get(`[data-cy-systemtags-picker-tag=${tags[tag]}]`).should('be.visible') + .findByRole('checkbox').click({ force: true }) + cy.get('[data-cy-systemtags-picker-button-submit]').click() + + cy.wait('@getTagData') + cy.wait('@assignTagData') + cy.get('[data-cy-systemtags-picker]').should('not.exist') + + expectInlineTagForFile('file2.txt', [tag]) + expectInlineTagForFile('file4.txt', [tag]) + }) + + it('Can assign multiple tags to selection', () => { + cy.login(user1) + cy.visit('/apps/files') + + files.forEach((file) => { + getRowForFile(file).should('be.visible') + }) + selectAllFiles() + + triggerTagManagementDialogAction() + cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) + cy.get('[data-cy-systemtags-picker-tag-color]').should('have.length', 5) + + cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') + cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') + + const prevTag = Object.keys(tags)[3] + const tag1 = Object.keys(tags)[1] + const tag2 = Object.keys(tags)[2] + cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible') + .findByRole('checkbox').click({ force: true }) + cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible') + .findByRole('checkbox').click({ force: true }) + cy.get('[data-cy-systemtags-picker-button-submit]').click() + + cy.wait('@getTagData') + cy.wait('@assignTagData') + cy.get('@getTagData.all').should('have.length', 2) + cy.get('@assignTagData.all').should('have.length', 2) + cy.get('[data-cy-systemtags-picker]').should('not.exist') + + expectInlineTagForFile('file1.txt', [tag1, tag2]) + expectInlineTagForFile('file2.txt', [prevTag, tag1, tag2]) + expectInlineTagForFile('file3.txt', [tag1, tag2]) + expectInlineTagForFile('file4.txt', [prevTag, tag1, tag2]) + expectInlineTagForFile('file5.txt', [tag1, tag2]) + }) + + it('Can remove tag from selection', () => { + cy.login(user1) + cy.visit('/apps/files') + + files.forEach((file) => { + getRowForFile(file).should('be.visible') + }) + selectRowForFile('file1.txt') + selectRowForFile('file3.txt') + selectRowForFile('file4.txt') + + triggerTagManagementDialogAction() + cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) + + cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') + cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') + + const firstTag = Object.keys(tags)[3] + const tag1 = Object.keys(tags)[1] + const tag2 = Object.keys(tags)[2] + cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible') + .findByRole('checkbox').click({ force: true }) + cy.get('[data-cy-systemtags-picker-button-submit]').click() + + cy.wait('@getTagData') + cy.wait('@assignTagData') + cy.get('[data-cy-systemtags-picker]').should('not.exist') + + expectInlineTagForFile('file1.txt', [tag1]) + expectInlineTagForFile('file2.txt', [firstTag, tag1, tag2]) + expectInlineTagForFile('file3.txt', [tag1]) + expectInlineTagForFile('file4.txt', [firstTag, tag1]) + expectInlineTagForFile('file5.txt', [tag1, tag2]) + + }) + + it('Can remove multiple tags from selection', () => { + cy.login(user1) + cy.visit('/apps/files') + + files.forEach((file) => { + getRowForFile(file).should('be.visible') + }) + selectAllFiles() + + triggerTagManagementDialogAction() + cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) + + cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') + cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') + + cy.get('[data-cy-systemtags-picker-tag] input:indeterminate').should('exist') + .click({ force: true, multiple: true }) + // indeterminate became checked + cy.get('[data-cy-systemtags-picker-tag] input:checked').should('exist') + .click({ force: true, multiple: true }) + // now all are unchecked + cy.get('[data-cy-systemtags-picker-button-submit]').click() + + cy.wait('@getTagData') + cy.wait('@assignTagData') + cy.get('@getTagData.all').should('have.length', 3) + cy.get('@assignTagData.all').should('have.length', 3) + cy.get('[data-cy-systemtags-picker]').should('not.exist') + + expectInlineTagForFile('file1.txt', []) + expectInlineTagForFile('file2.txt', []) + expectInlineTagForFile('file3.txt', []) + expectInlineTagForFile('file4.txt', []) + expectInlineTagForFile('file5.txt', []) + }) + + it('Can assign and remove multiple tags as a secondary user', () => { + // Create new users + cy.createRandomUser().then((_user1) => { + user1 = _user1 + cy.createRandomUser().then((_user2) => { + user2 = _user2 + }) + + files.forEach((file) => { + cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file) + }) + }) + + cy.login(user1) + cy.visit('/apps/files') + + files.forEach((file) => { + getRowForFile(file).should('be.visible') + }) + selectAllFiles() + + triggerTagManagementDialogAction() + cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) + + cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData1') + cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData1') + + const tag1 = Object.keys(tags)[0] + const tag2 = Object.keys(tags)[3] + cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible') + .findByRole('checkbox').click({ force: true }) + cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible') + .findByRole('checkbox').click({ force: true }) + cy.get('[data-cy-systemtags-picker-button-submit]').click() + + cy.wait('@getTagData1') + cy.wait('@assignTagData1') + cy.get('@getTagData1.all').should('have.length', 2) + cy.get('@assignTagData1.all').should('have.length', 2) + cy.get('[data-cy-systemtags-picker]').should('not.exist') + + expectInlineTagForFile('file1.txt', [tag1, tag2]) + expectInlineTagForFile('file2.txt', [tag1, tag2]) + expectInlineTagForFile('file3.txt', [tag1, tag2]) + expectInlineTagForFile('file4.txt', [tag1, tag2]) + expectInlineTagForFile('file5.txt', [tag1, tag2]) + + createShare('file1.txt', user2.userId) + createShare('file3.txt', user2.userId) + + cy.login(user2) + cy.visit('/apps/files') + + getRowForFile('file1.txt').should('be.visible') + getRowForFile('file3.txt').should('be.visible') + + expectInlineTagForFile('file1.txt', [tag1, tag2]) + expectInlineTagForFile('file3.txt', [tag1, tag2]) + + selectRowForFile('file1.txt') + selectRowForFile('file3.txt') + triggerTagManagementDialogAction() + cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) + + cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData2') + cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData2') + + cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible') + .findByRole('checkbox').click({ force: true }) + cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible') + .findByRole('checkbox').click({ force: true }) + cy.get('[data-cy-systemtags-picker-button-submit]').click() + + cy.wait('@getTagData2') + cy.wait('@assignTagData2') + cy.get('@getTagData2.all').should('have.length', 2) + cy.get('@assignTagData2.all').should('have.length', 2) + cy.get('[data-cy-systemtags-picker]').should('not.exist') + + expectInlineTagForFile('file1.txt', []) + expectInlineTagForFile('file3.txt', []) + + cy.login(user1) + cy.visit('/apps/files') + + expectInlineTagForFile('file1.txt', []) + expectInlineTagForFile('file3.txt', []) + }) + + it('Can create tag and assign files to it', () => { + cy.createRandomUser().then((user1) => { + files.forEach((file) => { + cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file) + }) + + cy.login(user1) + cy.visit('/apps/files') + + files.forEach((file) => { + getRowForFile(file).should('be.visible') + }) + selectAllFiles() + + triggerTagManagementDialogAction() + cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) + + cy.intercept('POST', '/remote.php/dav/systemtags').as('createTag') + cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') + cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') + + const newTag = randomBytes(8).toString('base64').slice(0, 6) + cy.get('[data-cy-systemtags-picker-input]').type(newTag) + + cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 0) + cy.get('[data-cy-systemtags-picker-button-create]').should('be.visible') + cy.get('[data-cy-systemtags-picker-button-create]').click() + + cy.wait('@createTag') + cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 6) + // Verify the new tag is selected by default + cy.get('[data-cy-systemtags-picker-tag]').contains(newTag) + .parents('[data-cy-systemtags-picker-tag]') + .findByRole('checkbox', { hidden: true }).should('be.checked') + + // Apply changes + cy.get('[data-cy-systemtags-picker-button-submit]').click() + + cy.wait('@getTagData') + cy.wait('@assignTagData') + cy.get('@getTagData.all').should('have.length', 1) + cy.get('@assignTagData.all').should('have.length', 1) + cy.get('[data-cy-systemtags-picker]').should('not.exist') + + expectInlineTagForFile('file1.txt', [newTag]) + expectInlineTagForFile('file2.txt', [newTag]) + expectInlineTagForFile('file3.txt', [newTag]) + expectInlineTagForFile('file4.txt', [newTag]) + expectInlineTagForFile('file5.txt', [newTag]) + }) + }) + + it('Cannot create tag if restriction is in place', () => { + let tagId: string + + cy.runOccCommand('config:app:set systemtags restrict_creation_to_admin --value 1') + cy.runOccCommand('tag:add testTag public --output json').then(({ stdout }) => { + const tag = JSON.parse(stdout) + tagId = tag.id + }) + + cy.createRandomUser().then((user1) => { + files.forEach((file) => { + cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file) + }) + + cy.login(user1) + cy.visit('/apps/files') + + files.forEach((file) => { + getRowForFile(file).should('be.visible') + }) + selectAllFiles() + + triggerTagManagementDialogAction() + + cy.findByRole('textbox', { name: 'Search or create tag' }).should('not.exist') + cy.findByRole('textbox', { name: 'Search tag' }).should('be.visible') + + cy.get('[data-cy-systemtags-picker-input]').type('testTag') + + cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 1) + cy.get('[data-cy-systemtags-picker-button-create]').should('not.exist') + cy.get('[data-cy-systemtags-picker-tag-color]').should('not.exist') + + // Assign the tag + cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') + cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') + + cy.get(`[data-cy-systemtags-picker-tag="${tagId}"]`).should('be.visible') + .findByRole('checkbox').click({ force: true }) + cy.get('[data-cy-systemtags-picker-button-submit]').click() + + cy.wait('@getTagData') + cy.wait('@assignTagData') + + cy.get('[data-cy-systemtags-picker]').should('not.exist') + + // Finally, reset the restriction + cy.runOccCommand('config:app:set systemtags restrict_creation_to_admin --value 0') + }) + }) + + it('Can search for tags with insensitive case', () => { + let tagId: string + resetTags() + + cy.runOccCommand('tag:add TESTTAG public --output json').then(({ stdout }) => { + const tag = JSON.parse(stdout) + tagId = tag.id + }) + + cy.createRandomUser().then((user1) => { + files.forEach((file) => { + cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file) + }) + + cy.login(user1) + cy.visit('/apps/files') + + files.forEach((file) => { + getRowForFile(file).should('be.visible') + }) + selectAllFiles() + + triggerTagManagementDialogAction() + + cy.findByRole('textbox', { name: 'Search or create tag' }).should('be.visible') + cy.findByRole('textbox', { name: 'Search tag' }).should('not.exist') + + cy.get('[data-cy-systemtags-picker-input]').type('testtag') + + cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 1) + cy.get(`[data-cy-systemtags-picker-tag="${tagId}"]`).should('be.visible') + .findByRole('checkbox').should('not.be.checked') + + // Assign the tag + cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') + cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') + + cy.get(`[data-cy-systemtags-picker-tag="${tagId}"]`).should('be.visible') + .findByRole('checkbox').click({ force: true }) + cy.get('[data-cy-systemtags-picker-button-submit]').click() + + cy.wait('@getTagData') + cy.wait('@assignTagData') + + expectInlineTagForFile('file1.txt', ['TESTTAG']) + expectInlineTagForFile('file2.txt', ['TESTTAG']) + expectInlineTagForFile('file3.txt', ['TESTTAG']) + expectInlineTagForFile('file4.txt', ['TESTTAG']) + expectInlineTagForFile('file5.txt', ['TESTTAG']) + + cy.get('[data-cy-systemtags-picker]').should('not.exist') + }) + }) +}) diff --git a/cypress/e2e/systemtags/files-inline-action.cy.ts b/cypress/e2e/systemtags/files-inline-action.cy.ts new file mode 100644 index 00000000000..e1199972a5d --- /dev/null +++ b/cypress/e2e/systemtags/files-inline-action.cy.ts @@ -0,0 +1,172 @@ +/* eslint-disable no-unused-expressions */ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { User } from '@nextcloud/cypress' +import { randomBytes } from 'crypto' +import { closeSidebar, getRowForFile, triggerActionForFile } from '../files/FilesUtils.ts' + +describe('Systemtags: Files integration', { testIsolation: true }, () => { + let user: User + + beforeEach(() => cy.createRandomUser().then(($user) => { + user = $user + + cy.mkdir(user, '/folder') + cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') + cy.login(user) + cy.visit('/apps/files') + })) + + it('See first assigned tag in the file list', () => { + const tag = randomBytes(8).toString('base64') + + cy.intercept('PROPFIND', `**/remote.php/dav/files/${user.userId}/file.txt`).as('getNode') + getRowForFile('file.txt').should('be.visible') + triggerActionForFile('file.txt', 'details') + cy.wait('@getNode') + + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('button', { name: 'Actions' }) + .should('be.visible') + .click() + + cy.findByRole('menuitem', { name: 'Tags' }) + .should('be.visible') + .click() + + cy.intercept('PUT', '**/remote.php/dav/systemtags-relations/files/**').as('assignTag') + + getCollaborativeTagsInput() + .type(`{selectAll}${tag}{enter}`) + cy.wait('@assignTag') + cy.wait('@getNode') + + // Close the sidebar and reload to check the file list + closeSidebar() + cy.reload() + + getRowForFile('file.txt') + .findByRole('list', { name: /collaborative tags/i }) + .findByRole('listitem') + .should('be.visible') + .and('contain.text', tag) + }) + + it('See two assigned tags are also shown in the file list', () => { + const tag1 = randomBytes(5).toString('base64') + const tag2 = randomBytes(5).toString('base64') + + cy.intercept('PROPFIND', `**/remote.php/dav/files/${user.userId}/file.txt`).as('getNode') + getRowForFile('file.txt').should('be.visible') + triggerActionForFile('file.txt', 'details') + cy.wait('@getNode') + + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('button', { name: 'Actions' }) + .should('be.visible') + .click() + + cy.findByRole('menuitem', { name: 'Tags' }) + .should('be.visible') + .click() + + cy.intercept('PUT', '**/remote.php/dav/systemtags-relations/files/**').as('assignTag') + + // Assign first tag + getCollaborativeTagsInput() + .type(`{selectAll}${tag1}{enter}`) + cy.wait('@assignTag') + cy.wait('@getNode') + + // Assign second tag + getCollaborativeTagsInput() + .type(`{selectAll}${tag2}{enter}`) + cy.wait('@assignTag') + cy.wait('@getNode') + + // Close the sidebar and reload to check the file list + closeSidebar() + cy.reload() + + getRowForFile('file.txt') + .findByRole('list', { name: /collaborative tags/i }) + .children() + .should('have.length', 2) + .should('contain.text', tag1) + .should('contain.text', tag2) + }) + + it('See three assigned tags result in overflow entry', () => { + const tag1 = randomBytes(4).toString('base64') + const tag2 = randomBytes(4).toString('base64') + const tag3 = randomBytes(4).toString('base64') + + cy.intercept('PROPFIND', `**/remote.php/dav/files/${user.userId}/file.txt`).as('getNode') + getRowForFile('file.txt').should('be.visible') + triggerActionForFile('file.txt', 'details') + cy.wait('@getNode') + + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('button', { name: 'Actions' }) + .should('be.visible') + .click() + + cy.findByRole('menuitem', { name: 'Tags' }) + .should('be.visible') + .click() + + cy.intercept('PUT', '**/remote.php/dav/systemtags-relations/files/**').as('assignTag') + + // Assign first tag + getCollaborativeTagsInput() + .type(`{selectAll}${tag1}{enter}`) + cy.wait('@assignTag') + cy.wait('@getNode') + + // Assign second tag + getCollaborativeTagsInput() + .type(`{selectAll}${tag2}{enter}`) + cy.wait('@assignTag') + cy.wait('@getNode') + + // Assign third tag + getCollaborativeTagsInput() + .type(`{selectAll}${tag3}{enter}`) + cy.wait('@assignTag') + cy.wait('@getNode') + + // Close the sidebar and reload to check the file list + closeSidebar() + cy.reload() + + getRowForFile('file.txt') + .findByRole('list', { name: /collaborative tags/i }) + .children() + .then(($children) => { + expect($children.length).to.eq(4) + expect($children.get(0)).be.visible + expect($children.get(1)).be.visible + // not visible - just for accessibility + expect($children.get(2)).not.be.visible + expect($children.get(3)).not.be.visible + // Text content + expect($children.get(1)).contain.text('+2') + // Remove the '+x' element + const elements = [$children.get(0), ...$children.get().slice(2)] + .map((el) => el.innerText.trim()) + expect(elements).to.have.members([tag1, tag2, tag3]) + }) + }) +}) + +function getCollaborativeTagsInput(): Cypress.Chainable<JQuery<HTMLElement>> { + return cy.get('[data-cy-sidebar]') + .findByRole('combobox', { name: /collaborative tags/i }) + .should('be.visible') + .should('not.have.attr', 'disabled', { timeout: 5000 }) +} diff --git a/cypress/e2e/systemtags/files-sidebar.cy.ts b/cypress/e2e/systemtags/files-sidebar.cy.ts new file mode 100644 index 00000000000..c6e6fda50d4 --- /dev/null +++ b/cypress/e2e/systemtags/files-sidebar.cy.ts @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { randomBytes } from 'crypto' +import { getRowForFile, triggerActionForFile } from '../files/FilesUtils.ts' + +describe('Systemtags: Files sidebar integration', { testIsolation: true }, () => { + let user: User + + beforeEach(() => cy.createRandomUser().then(($user) => { + user = $user + + cy.mkdir(user, '/folder') + cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') + cy.login(user) + })) + + it('Can assign tags using the sidebar', () => { + const tag = randomBytes(8).toString('base64') + cy.visit('/apps/files') + + getRowForFile('file.txt').should('be.visible') + triggerActionForFile('file.txt', 'details') + + cy.get('[data-cy-sidebar]') + .should('be.visible') + .findByRole('button', { name: 'Actions' }) + .should('be.visible') + .click() + + cy.findByRole('menuitem', { name: 'Tags' }) + .click() + + cy.intercept('PUT', '**/remote.php/dav/systemtags-relations/files/**').as('assignTag') + cy.get('[data-cy-sidebar]') + .findByRole('combobox', { name: /collaborative tags/i }) + .should('be.visible') + .type(`${tag}{enter}`) + cy.wait('@assignTag') + }) +}) diff --git a/cypress/e2e/theming/a11y-color-contrast.cy.ts b/cypress/e2e/theming/a11y-color-contrast.cy.ts index 03a6814ea1f..bff7df28e8e 100644 --- a/cypress/e2e/theming/a11y-color-contrast.cy.ts +++ b/cypress/e2e/theming/a11y-color-contrast.cy.ts @@ -1,3 +1,7 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ const themesToTest = ['light', 'dark', 'light-highcontrast', 'dark-highcontrast'] const testCases = { @@ -106,7 +110,7 @@ describe('Accessibility of Nextcloud theming colors', () => { before(() => { cy.createRandomUser().then(($user) => { // set user theme - cy.runOccCommand(`user:setting -- '${$user.userId}' theming enabled-themes '["${theme}"]'`) + cy.runOccCommand(`user:setting -- '${$user.userId}' theming enabled-themes '[\\"${theme}\\"]'`) cy.login($user) cy.visit('/') cy.injectAxe({ axeCorePath: 'node_modules/axe-core/axe.min.js' }) @@ -118,7 +122,7 @@ describe('Accessibility of Nextcloud theming colors', () => { // Unset background image and thus use background-color for testing blur background (images do not work with axe-core) doc.body.style.backgroundImage = 'unset' - const root = doc.querySelector('main') + const root = doc.querySelector('#content') if (root === null) { throw new Error('No test root found') } @@ -133,7 +137,7 @@ describe('Accessibility of Nextcloud theming colors', () => { it(`color contrast of ${foreground} on ${background}`, () => { cy.document().then(doc => { const element = createTestCase(foreground, background) - const root = doc.querySelector('main') + const root = doc.querySelector('#content') // eslint-disable-next-line no-unused-expressions expect(root).not.to.be.undefined // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/cypress/e2e/theming/admin-settings.cy.ts b/cypress/e2e/theming/admin-settings.cy.ts index 1c4e3458aae..4207b98f711 100644 --- a/cypress/e2e/theming/admin-settings.cy.ts +++ b/cypress/e2e/theming/admin-settings.cy.ts @@ -1,29 +1,19 @@ /** - * @copyright Copyright (c) 2022 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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ /* eslint-disable n/no-unpublished-import */ import { User } from '@nextcloud/cypress' -import { colord } from 'colord' -import { defaultPrimary, defaultBackground, pickRandomColor, validateBodyThemingCss, validateUserThemingDefaultCss } from './themingUtils' +import { + defaultPrimary, + defaultBackground, + pickRandomColor, + validateBodyThemingCss, + validateUserThemingDefaultCss, + expectBackgroundColor, +} from './themingUtils' +import { NavigationHeader } from '../../pages/NavigationHeader' const admin = new User('admin', 'admin') @@ -36,15 +26,24 @@ describe('Admin theming settings visibility check', function() { it('See the admin theming section', function() { cy.visit('/settings/admin/theming') - cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView() + cy.get('[data-admin-theming-settings]') + .should('exist') + .scrollIntoView() cy.get('[data-admin-theming-settings]').should('be.visible') }) it('See the default settings', function() { - cy.get('[data-admin-theming-setting-primary-color-picker]').should('exist') - cy.get('[data-admin-theming-setting-primary-color-reset]').should('not.exist') + cy.get('[data-admin-theming-setting-color-picker]').should('exist') cy.get('[data-admin-theming-setting-file-reset]').should('not.exist') - cy.get('[data-admin-theming-setting-file-remove]').should('be.visible') + cy.get('[data-admin-theming-setting-file-remove]').should('exist') + + cy.get( + '[data-admin-theming-setting-primary-color] [data-admin-theming-setting-color]', + ).then(($el) => expectBackgroundColor($el, defaultPrimary)) + + cy.get( + '[data-admin-theming-setting-background-color] [data-admin-theming-setting-color]', + ).then(($el) => expectBackgroundColor($el, defaultPrimary)) }) }) @@ -59,24 +58,42 @@ describe('Change the primary color and reset it', function() { it('See the admin theming section', function() { cy.visit('/settings/admin/theming') - cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView() + cy.get('[data-admin-theming-settings]') + .should('exist') + .scrollIntoView() cy.get('[data-admin-theming-settings]').should('be.visible') }) it('Change the primary color', function() { cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - pickRandomColor().then(color => { selectedColor = color }) + pickRandomColor('[data-admin-theming-setting-primary-color]').then( + (color) => { + selectedColor = color + }, + ) cy.wait('@setColor') - cy.waitUntil(() => validateBodyThemingCss(selectedColor, defaultBackground)) + cy.waitUntil(() => + validateBodyThemingCss( + selectedColor, + defaultBackground, + defaultPrimary, + ), + ) }) it('Screenshot the login page and validate login page', function() { cy.logout() cy.visit('/') - cy.waitUntil(() => validateBodyThemingCss(selectedColor, defaultBackground)) + cy.waitUntil(() => + validateBodyThemingCss( + selectedColor, + defaultBackground, + defaultPrimary, + ), + ) cy.screenshot() }) @@ -98,21 +115,29 @@ describe('Remove the default background and restore it', function() { it('See the admin theming section', function() { cy.visit('/settings/admin/theming') - cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView() + cy.get('[data-admin-theming-settings]') + .should('exist') + .scrollIntoView() cy.get('[data-admin-theming-settings]').should('be.visible') }) it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') + cy.intercept('*/apps/theming/ajax/updateStylesheet').as( + 'removeBackground', + ) cy.get('[data-admin-theming-setting-file-remove]').click() cy.wait('@removeBackground') cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null)) - cy.waitUntil(() => cy.window().then((win) => { - const backgroundPlain = getComputedStyle(win.document.body).getPropertyValue('--image-background-plain') - return backgroundPlain !== '' - })) + cy.waitUntil(() => + cy.window().then((win) => { + const backgroundPlain = getComputedStyle( + win.document.body, + ).getPropertyValue('--image-background') + return backgroundPlain !== '' + }), + ) }) it('Screenshot the login page and validate login page', function() { @@ -132,7 +157,7 @@ describe('Remove the default background and restore it', function() { }) }) -describe('Remove the default background with a custom primary color', function() { +describe('Remove the default background with a custom background color', function() { let selectedColor = '' before(function() { @@ -143,23 +168,40 @@ describe('Remove the default background with a custom primary color', function() it('See the admin theming section', function() { cy.visit('/settings/admin/theming') - cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView() + cy.get('[data-admin-theming-settings]') + .should('exist') + .scrollIntoView() cy.get('[data-admin-theming-settings]').should('be.visible') }) - it('Change the primary color', function() { + it('Change the background color', function() { cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - pickRandomColor().then(color => { selectedColor = color }) + pickRandomColor('[data-admin-theming-setting-background-color]').then( + (color) => { + selectedColor = color + }, + ) cy.wait('@setColor') - cy.waitUntil(() => validateBodyThemingCss(selectedColor, defaultBackground)) + cy.waitUntil(() => + validateBodyThemingCss( + defaultPrimary, + defaultBackground, + selectedColor, + ), + ) }) it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') + cy.intercept('*/apps/theming/ajax/updateStylesheet').as( + 'removeBackground', + ) - cy.get('[data-admin-theming-setting-file-remove]').click() + cy.get('[data-admin-theming-setting-file-remove]').scrollIntoView() + cy.get('[data-admin-theming-setting-file-remove]').click({ + force: true, + }) cy.wait('@removeBackground') }) @@ -168,7 +210,9 @@ describe('Remove the default background with a custom primary color', function() cy.logout() cy.visit('/') - cy.waitUntil(() => validateBodyThemingCss(selectedColor, null)) + cy.waitUntil(() => + validateBodyThemingCss(defaultPrimary, null, selectedColor), + ) cy.screenshot() }) @@ -182,6 +226,9 @@ describe('Remove the default background with a custom primary color', function() }) describe('Remove the default background with a bright color', function() { + const navigationHeader = new NavigationHeader() + let selectedColor = '' + before(function() { // Just in case previous test failed cy.resetAdminTheming() @@ -191,37 +238,52 @@ describe('Remove the default background with a bright color', function() { it('See the admin theming section', function() { cy.visit('/settings/admin/theming') - cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView() + cy.get('[data-admin-theming-settings]') + .should('exist') + .scrollIntoView() cy.get('[data-admin-theming-settings]').should('be.visible') }) it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') + cy.intercept('*/apps/theming/ajax/updateStylesheet').as( + 'removeBackground', + ) cy.get('[data-admin-theming-setting-file-remove]').click() cy.wait('@removeBackground') }) - it('Change the primary color', function() { + it('Change the background color', function() { cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') // Pick one of the bright color preset - cy.get('[data-admin-theming-setting-primary-color-picker]').click() - cy.get('.color-picker__simple-color-circle:eq(4)').click() + pickRandomColor( + '[data-admin-theming-setting-background-color]', + 4, + ).then((color) => { + selectedColor = color + }) cy.wait('@setColor') - cy.waitUntil(() => validateBodyThemingCss('#ddcb55', null)) + cy.waitUntil(() => + validateBodyThemingCss(defaultPrimary, null, selectedColor), + ) }) it('See the header being inverted', function() { - cy.waitUntil(() => cy.window().then((win) => { - const firstEntry = win.document.querySelector('.app-menu-main li img') - if (!firstEntry) { - return false - } - return getComputedStyle(firstEntry).filter === 'invert(1)' - })) + cy.waitUntil(() => + navigationHeader + .getNavigationEntries() + .find('img') + .then((el) => { + let ret = true + el.each(function() { + ret = ret && window.getComputedStyle(this).filter === 'invert(1)' + }) + return ret + }) + ) }) }) @@ -238,7 +300,9 @@ describe('Change the login fields then reset them', function() { it('See the admin theming section', function() { cy.visit('/settings/admin/theming') - cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView() + cy.get('[data-admin-theming-settings]') + .should('exist') + .scrollIntoView() cy.get('[data-admin-theming-settings]').should('be.visible') }) @@ -246,42 +310,54 @@ describe('Change the login fields then reset them', function() { cy.intercept('*/apps/theming/ajax/updateStylesheet').as('updateFields') // Name - cy.get('[data-admin-theming-setting-field="name"] input[type="text"]') - .scrollIntoView() - cy.get('[data-admin-theming-setting-field="name"] input[type="text"]') - .type(`{selectall}${name}{enter}`) + cy.get( + '[data-admin-theming-setting-field="name"] input[type="text"]', + ).scrollIntoView() + cy.get( + '[data-admin-theming-setting-field="name"] input[type="text"]', + ).type(`{selectall}${name}{enter}`) cy.wait('@updateFields') // Url - cy.get('[data-admin-theming-setting-field="url"] input[type="url"]') - .scrollIntoView() - cy.get('[data-admin-theming-setting-field="url"] input[type="url"]') - .type(`{selectall}${url}{enter}`) + cy.get( + '[data-admin-theming-setting-field="url"] input[type="url"]', + ).scrollIntoView() + cy.get( + '[data-admin-theming-setting-field="url"] input[type="url"]', + ).type(`{selectall}${url}{enter}`) cy.wait('@updateFields') // Slogan - cy.get('[data-admin-theming-setting-field="slogan"] input[type="text"]') - .scrollIntoView() - cy.get('[data-admin-theming-setting-field="slogan"] input[type="text"]') - .type(`{selectall}${slogan}{enter}`) + cy.get( + '[data-admin-theming-setting-field="slogan"] input[type="text"]', + ).scrollIntoView() + cy.get( + '[data-admin-theming-setting-field="slogan"] input[type="text"]', + ).type(`{selectall}${slogan}{enter}`) cy.wait('@updateFields') }) it('Ensure undo button presence', function() { - cy.get('[data-admin-theming-setting-field="name"] .input-field__trailing-button') - .scrollIntoView() - cy.get('[data-admin-theming-setting-field="name"] .input-field__trailing-button') - .should('be.visible') - - cy.get('[data-admin-theming-setting-field="url"] .input-field__trailing-button') - .scrollIntoView() - cy.get('[data-admin-theming-setting-field="url"] .input-field__trailing-button') - .should('be.visible') - - cy.get('[data-admin-theming-setting-field="slogan"] .input-field__trailing-button') - .scrollIntoView() - cy.get('[data-admin-theming-setting-field="slogan"] .input-field__trailing-button') - .should('be.visible') + cy.get( + '[data-admin-theming-setting-field="name"] .input-field__trailing-button', + ).scrollIntoView() + cy.get( + '[data-admin-theming-setting-field="name"] .input-field__trailing-button', + ).should('be.visible') + + cy.get( + '[data-admin-theming-setting-field="url"] .input-field__trailing-button', + ).scrollIntoView() + cy.get( + '[data-admin-theming-setting-field="url"] .input-field__trailing-button', + ).should('be.visible') + + cy.get( + '[data-admin-theming-setting-field="slogan"] .input-field__trailing-button', + ).scrollIntoView() + cy.get( + '[data-admin-theming-setting-field="slogan"] .input-field__trailing-button', + ).should('be.visible') }) it('Validate login screen changes', function() { @@ -317,19 +393,29 @@ describe('Disable user theming and enable it back', function() { it('See the admin theming section', function() { cy.visit('/settings/admin/theming') - cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView() + cy.get('[data-admin-theming-settings]') + .should('exist') + .scrollIntoView() cy.get('[data-admin-theming-settings]').should('be.visible') }) it('Disable user background theming', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('disableUserTheming') - - cy.get('[data-admin-theming-setting-disable-user-theming]') - .scrollIntoView() - cy.get('[data-admin-theming-setting-disable-user-theming]') - .should('be.visible') - cy.get('[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]').check({ force: true }) - cy.get('[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]').should('be.checked') + cy.intercept('*/apps/theming/ajax/updateStylesheet').as( + 'disableUserTheming', + ) + + cy.get( + '[data-admin-theming-setting-disable-user-theming]', + ).scrollIntoView() + cy.get('[data-admin-theming-setting-disable-user-theming]').should( + 'be.visible', + ) + cy.get( + '[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]', + ).check({ force: true }) + cy.get( + '[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]', + ).should('be.checked') cy.wait('@disableUserTheming') }) @@ -343,8 +429,9 @@ describe('Disable user theming and enable it back', function() { it('User cannot not change background settings', function() { cy.visit('/settings/user/theming') - cy.get('[data-user-theming-background-disabled]').scrollIntoView() - cy.get('[data-user-theming-background-disabled]').should('be.visible') + cy.contains( + 'Customization has been disabled by your administrator', + ).should('exist') }) }) @@ -363,40 +450,60 @@ describe('The user default background settings reflect the admin theming setting it('See the admin theming section', function() { cy.visit('/settings/admin/theming') - cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView() + cy.get('[data-admin-theming-settings]') + .should('exist') + .scrollIntoView() cy.get('[data-admin-theming-settings]').should('be.visible') }) - it('Change the primary color', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - - pickRandomColor().then(color => { selectedColor = color }) - - cy.wait('@setColor') - cy.waitUntil(() => cy.window().then(($window) => { - const primary = $window.getComputedStyle($window.document.body).getPropertyValue('--color-primary-default') - return colord(primary).isEqual(selectedColor) - })) - }) - it('Change the default background', function() { cy.intercept('*/apps/theming/ajax/uploadImage').as('setBackground') cy.fixture('image.jpg', null).as('background') - cy.get('[data-admin-theming-setting-file="background"] input[type="file"]').selectFile('@background', { force: true }) + cy.get( + '[data-admin-theming-setting-file="background"] input[type="file"]', + ).selectFile('@background', { force: true }) cy.wait('@setBackground') - cy.waitUntil(() => cy.window().then((win) => { - const currentBackgroundDefault = getComputedStyle(win.document.body).getPropertyValue('--image-background-default') - return currentBackgroundDefault.includes('/apps/theming/image/background?v=') - })) + cy.waitUntil(() => + validateBodyThemingCss( + defaultPrimary, + '/apps/theming/image/background?v=', + null, + ), + ) + }) + + it('Change the background color', function() { + cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') + + pickRandomColor('[data-admin-theming-setting-background-color]').then( + (color) => { + selectedColor = color + }, + ) + + cy.wait('@setColor') + cy.waitUntil(() => + validateBodyThemingCss( + defaultPrimary, + '/apps/theming/image/background?v=', + selectedColor, + ), + ) }) it('Login page should match admin theming settings', function() { cy.logout() cy.visit('/') - cy.waitUntil(() => validateBodyThemingCss(selectedColor, '/apps/theming/image/background?v=')) + cy.waitUntil(() => + validateBodyThemingCss( + defaultPrimary, + '/apps/theming/image/background?v=', + selectedColor, + ), + ) }) it('Login as user', function() { @@ -413,9 +520,17 @@ describe('The user default background settings reflect the admin theming setting it('Default user background settings should match admin theming settings', function() { cy.get('[data-user-theming-background-default]').should('be.visible') - cy.get('[data-user-theming-background-default]').should('have.class', 'background--active') - - cy.waitUntil(() => validateUserThemingDefaultCss(selectedColor, '/apps/theming/image/background?v=')) + cy.get('[data-user-theming-background-default]').should( + 'have.class', + 'background--active', + ) + + cy.waitUntil(() => + validateUserThemingDefaultCss( + selectedColor, + '/apps/theming/image/background?v=', + ), + ) }) }) @@ -432,12 +547,16 @@ describe('The user default background settings reflect the admin theming setting it('See the admin theming section', function() { cy.visit('/settings/admin/theming') - cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView() + cy.get('[data-admin-theming-settings]') + .should('exist') + .scrollIntoView() cy.get('[data-admin-theming-settings]').should('be.visible') }) it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') + cy.intercept('*/apps/theming/ajax/updateStylesheet').as( + 'removeBackground', + ) cy.get('[data-admin-theming-setting-file-remove]').click() @@ -466,7 +585,10 @@ describe('The user default background settings reflect the admin theming setting it('Default user background settings should match admin theming settings', function() { cy.get('[data-user-theming-background-default]').should('be.visible') - cy.get('[data-user-theming-background-default]').should('have.class', 'background--active') + cy.get('[data-user-theming-background-default]').should( + 'have.class', + 'background--active', + ) cy.waitUntil(() => validateUserThemingDefaultCss(defaultPrimary, null)) }) diff --git a/cypress/e2e/theming/admin-settings_default-app.cy.ts b/cypress/e2e/theming/admin-settings_default-app.cy.ts new file mode 100644 index 00000000000..702f737bc15 --- /dev/null +++ b/cypress/e2e/theming/admin-settings_default-app.cy.ts @@ -0,0 +1,91 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' +import { NavigationHeader } from '../../pages/NavigationHeader' + +const admin = new User('admin', 'admin') + +describe('Admin theming set default apps', () => { + const navigationHeader = new NavigationHeader() + + before(function() { + // Just in case previous test failed + cy.resetAdminTheming() + cy.login(admin) + }) + + it('See the current default app is the dashboard', () => { + // check default route + cy.visit('/') + cy.url().should('match', /apps\/dashboard/) + + // Also check the top logo link + navigationHeader.logo().click() + cy.url().should('match', /apps\/dashboard/) + }) + + it('See the default app settings', () => { + cy.visit('/settings/admin/theming') + + cy.get('.settings-section').contains('Navigation bar settings').should('exist') + cy.get('[data-cy-switch-default-app]').should('exist') + cy.get('[data-cy-switch-default-app]').scrollIntoView() + }) + + it('Toggle the "use custom default app" switch', () => { + cy.get('[data-cy-switch-default-app] input').should('not.be.checked') + cy.get('[data-cy-switch-default-app] .checkbox-content').click() + cy.get('[data-cy-switch-default-app] input').should('be.checked') + }) + + it('See the default app order selector', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { + const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() + expect(appIDs).to.deep.eq(['dashboard', 'files']) + }) + }) + + it('Change the default app', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"]').scrollIntoView() + + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible') + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click() + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible') + + }) + + it('See the default app is changed', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { + const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() + expect(appIDs).to.deep.eq(['files', 'dashboard']) + }) + + // Check the redirect to the default app works + cy.request({ url: '/', followRedirect: false }).then((response) => { + expect(response.status).to.eq(302) + expect(response).to.have.property('headers') + expect(response.headers.location).to.contain('/apps/files') + }) + }) + + it('Toggle the "use custom default app" switch back to reset the default apps', () => { + cy.visit('/settings/admin/theming') + cy.get('[data-cy-switch-default-app]').scrollIntoView() + + cy.get('[data-cy-switch-default-app] input').should('be.checked') + cy.get('[data-cy-switch-default-app] .checkbox-content').click() + cy.get('[data-cy-switch-default-app] input').should('be.not.checked') + }) + + it('See the default app is changed back to default', () => { + // Check the redirect to the default app works + cy.request({ url: '/', followRedirect: false }).then((response) => { + expect(response.status).to.eq(302) + expect(response).to.have.property('headers') + expect(response.headers.location).to.contain('/apps/dashboard') + }) + }) +}) diff --git a/cypress/e2e/theming/admin-settings_urls.cy.ts b/cypress/e2e/theming/admin-settings_urls.cy.ts new file mode 100644 index 00000000000..46bae7901c4 --- /dev/null +++ b/cypress/e2e/theming/admin-settings_urls.cy.ts @@ -0,0 +1,143 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { User } from '@nextcloud/cypress' + +const admin = new User('admin', 'admin') + +describe('Admin theming: Setting custom project URLs', function() { + this.beforeEach(() => { + // Just in case previous test failed + cy.resetAdminTheming() + cy.login(admin) + cy.visit('/settings/admin/theming') + cy.intercept('POST', '**/apps/theming/ajax/updateStylesheet').as('updateTheming') + }) + + it('Setting the web link', () => { + cy.findByRole('textbox', { name: /web link/i }) + .and('have.attr', 'type', 'url') + .as('input') + .scrollIntoView() + cy.get('@input') + .should('be.visible') + .type('{selectAll}http://example.com/path?query#fragment{enter}') + + cy.wait('@updateTheming') + + cy.logout() + + cy.visit('/') + cy.contains('a', 'Nextcloud') + .should('be.visible') + .and('have.attr', 'href', 'http://example.com/path?query#fragment') + }) + + it('Setting the legal notice link', () => { + cy.findByRole('textbox', { name: /legal notice link/i }) + .should('exist') + .and('have.attr', 'type', 'url') + .as('input') + .scrollIntoView() + cy.get('@input') + .type('http://example.com/path?query#fragment{enter}') + + cy.wait('@updateTheming') + + cy.logout() + + cy.visit('/') + cy.contains('a', /legal notice/i) + .should('be.visible') + .and('have.attr', 'href', 'http://example.com/path?query#fragment') + }) + + it('Setting the privacy policy link', () => { + cy.findByRole('textbox', { name: /privacy policy link/i }) + .should('exist') + .as('input') + .scrollIntoView() + cy.get('@input') + .should('have.attr', 'type', 'url') + .type('http://privacy.local/path?query#fragment{enter}') + + cy.wait('@updateTheming') + + cy.logout() + + cy.visit('/') + cy.contains('a', /privacy policy/i) + .should('be.visible') + .and('have.attr', 'href', 'http://privacy.local/path?query#fragment') + }) + +}) + +describe('Admin theming: Web link corner cases', function() { + this.beforeEach(() => { + // Just in case previous test failed + cy.resetAdminTheming() + cy.login(admin) + cy.visit('/settings/admin/theming') + cy.intercept('POST', '**/apps/theming/ajax/updateStylesheet').as('updateTheming') + }) + + it('Already URL encoded', () => { + cy.findByRole('textbox', { name: /web link/i }) + .and('have.attr', 'type', 'url') + .as('input') + .scrollIntoView() + cy.get('@input') + .should('be.visible') + .type('{selectAll}http://example.com/%22path%20with%20space%22{enter}') + + cy.wait('@updateTheming') + + cy.logout() + + cy.visit('/') + cy.contains('a', 'Nextcloud') + .should('be.visible') + .and('have.attr', 'href', 'http://example.com/%22path%20with%20space%22') + }) + + it('URL with double quotes', () => { + cy.findByRole('textbox', { name: /web link/i }) + .and('have.attr', 'type', 'url') + .as('input') + .scrollIntoView() + cy.get('@input') + .should('be.visible') + .type('{selectAll}http://example.com/"path"{enter}') + + cy.wait('@updateTheming') + + cy.logout() + + cy.visit('/') + cy.contains('a', 'Nextcloud') + .should('be.visible') + .and('have.attr', 'href', 'http://example.com/%22path%22') + }) + + it('URL with double quotes and already encoded', () => { + cy.findByRole('textbox', { name: /web link/i }) + .and('have.attr', 'type', 'url') + .as('input') + .scrollIntoView() + cy.get('@input') + .should('be.visible') + .type('{selectAll}http://example.com/"the%20path"{enter}') + + cy.wait('@updateTheming') + + cy.logout() + + cy.visit('/') + cy.contains('a', 'Nextcloud') + .should('be.visible') + .and('have.attr', 'href', 'http://example.com/%22the%20path%22') + }) + +}) diff --git a/cypress/e2e/theming/themingUtils.ts b/cypress/e2e/theming/themingUtils.ts index 2965886c656..b4740beda1c 100644 --- a/cypress/e2e/theming/themingUtils.ts +++ b/cypress/e2e/theming/themingUtils.ts @@ -1,49 +1,57 @@ /** - * @copyright Copyright (c) 2022 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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { colord } from 'colord' -const defaultNextcloudBlue = '#0082c9' export const defaultPrimary = '#00679e' -export const defaultBackground = 'kamil-porembinski-clouds.jpg' +export const defaultBackground = 'jenna-kim-the-globe.webp' + +/** + * Check if a CSS variable is set to a specific color + * @param variable Variable to check + * @param expectedColor Color that is expected + */ +export function validateCSSVariable(variable: string, expectedColor: string) { + const value = window.getComputedStyle(Cypress.$('body').get(0)).getPropertyValue(variable) + console.debug(`${variable}, is: ${colord(value).toHex()} expected: ${expectedColor}`) + return colord(value).isEqual(expectedColor) +} /** * Validate the current page body css variables * - * @param {string} expectedColor the expected color + * @param {string} expectedColor the expected primary color * @param {string|null} expectedBackground the expected background + * @param {string|null} expectedBackgroundColor the expected background color (null to ignore) */ -export const validateBodyThemingCss = function(expectedColor = defaultPrimary, expectedBackground: string|null = defaultBackground) { +export function validateBodyThemingCss(expectedColor = defaultPrimary, expectedBackground: string|null = defaultBackground, expectedBackgroundColor: string|null = defaultPrimary) { // We must use `Cypress.$` here as any assertions (get is an assertion) is not allowed in wait-until's check function, see documentation const guestBackgroundColor = Cypress.$('body').css('background-color') const guestBackgroundImage = Cypress.$('body').css('background-image') - const isValidBackgroundColor = colord(guestBackgroundColor).isEqual(expectedColor) + const isValidBackgroundColor = expectedBackgroundColor === null || colord(guestBackgroundColor).isEqual(expectedBackgroundColor) const isValidBackgroundImage = !expectedBackground ? guestBackgroundImage === 'none' : guestBackgroundImage.includes(expectedBackground) - console.debug({ guestBackgroundColor: colord(guestBackgroundColor).toHex(), guestBackgroundImage, expectedColor, expectedBackground, isValidBackgroundColor, isValidBackgroundImage }) + console.debug({ + isValidBackgroundColor, + isValidBackgroundImage, + guestBackgroundColor: colord(guestBackgroundColor).toHex(), + guestBackgroundImage, + }) - return isValidBackgroundColor && isValidBackgroundImage + return isValidBackgroundColor && isValidBackgroundImage && validateCSSVariable('--color-primary', expectedColor) +} + +/** + * Check background color of element + * @param element JQuery element to check + * @param color expected color + */ +export function expectBackgroundColor(element: JQuery<HTMLElement>, color: string) { + expect(colord(element.css('background-color')).toHex()).equal(colord(color).toHex()) } /** @@ -58,28 +66,28 @@ export const validateUserThemingDefaultCss = function(expectedColor = defaultPri return false } - const defaultOptionBackground = defaultSelectButton.css('background-image') - const colorPickerOptionColor = defaultSelectButton.css('background-color') - const isNextcloudBlue = colord(colorPickerOptionColor).isEqual('#0082c9') + const backgroundImage = defaultSelectButton.css('background-image') + const backgroundColor = defaultSelectButton.css('background-color') const isValidBackgroundImage = !expectedBackground - ? defaultOptionBackground === 'none' - : defaultOptionBackground.includes(expectedBackground) - - console.debug({ colorPickerOptionColor: colord(colorPickerOptionColor).toHex(), expectedColor, isValidBackgroundImage, isNextcloudBlue }) + ? (backgroundImage === 'none' || Cypress.$('body').css('background-image') === 'none') + : backgroundImage.includes(expectedBackground) + + console.debug({ + colorPickerOptionColor: colord(backgroundColor).toHex(), + expectedColor, + isValidBackgroundImage, + backgroundImage, + }) - return isValidBackgroundImage && ( - colord(colorPickerOptionColor).isEqual(expectedColor) - // we replace nextcloud blue with the the default rpimary (apps/theming/lib/Themes/DefaultTheme.php line 76) - || (isNextcloudBlue && colord(expectedColor).isEqual(defaultPrimary)) - ) + return isValidBackgroundImage && colord(backgroundColor).isEqual(expectedColor) } -export const pickRandomColor = function(): Cypress.Chainable<string> { +export const pickRandomColor = function(context: string, index?: number): Cypress.Chainable<string> { // Pick one of the first 8 options - const randColour = Math.floor(Math.random() * 8) + const randColour = index ?? Math.floor(Math.random() * 8) - const colorPreviewSelector = '[data-user-theming-background-color],[data-admin-theming-setting-primary-color]' + const colorPreviewSelector = `${context} [data-admin-theming-setting-color]` let oldColor = '' cy.get(colorPreviewSelector).then(($el) => { @@ -87,7 +95,8 @@ export const pickRandomColor = function(): Cypress.Chainable<string> { }) // Open picker - cy.contains('button', 'Change color').click() + cy.get(`${context} [data-admin-theming-setting-color-picker]`).scrollIntoView() + cy.get(`${context} [data-admin-theming-setting-color-picker]`).click({ force: true }) // Click on random color cy.get('.color-picker__simple-color-circle').eq(randColour).click() diff --git a/cypress/e2e/theming/navigation-bar-settings.cy.ts b/cypress/e2e/theming/user-settings_app-order.cy.ts index 4bea6225f76..11ef2f45382 100644 --- a/cypress/e2e/theming/navigation-bar-settings.cy.ts +++ b/cypress/e2e/theming/user-settings_app-order.cy.ts @@ -1,110 +1,23 @@ /** - * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de> - * - * @author Ferdinand Thiessen <opensource@fthiessen.de> - * - * @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: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { User } from '@nextcloud/cypress' import { installTestApp, uninstallTestApp } from '../../support/commonUtils' +import { NavigationHeader } from '../../pages/NavigationHeader' -const admin = new User('admin', 'admin') - -describe('Admin theming set default apps', () => { - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the current default app is the dashboard', () => { - cy.visit('/') - cy.url().should('match', /apps\/dashboard/) - - // Also check the top logo link - cy.get('#nextcloud').click() - cy.url().should('match', /apps\/dashboard/) - }) - - it('See the default app settings', () => { - cy.visit('/settings/admin/theming') - - cy.get('.settings-section').contains('Navigation bar settings').should('exist') - cy.get('[data-cy-switch-default-app]').should('exist') - cy.get('[data-cy-switch-default-app]').scrollIntoView() - }) - - it('Toggle the "use custom default app" switch', () => { - cy.get('[data-cy-switch-default-app] input').should('not.be.checked') - cy.get('[data-cy-switch-default-app] .checkbox-content').click() - cy.get('[data-cy-switch-default-app] input').should('be.checked') - }) - - it('See the default app order selector', () => { - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['dashboard', 'files']) - }) - }) - - it('Change the default app', () => { - cy.get('[data-cy-app-order] [data-cy-app-order-element="files"]').scrollIntoView() - - cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible') - cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click() - cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible') - - }) - - it('See the default app is changed', () => { - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['files', 'dashboard']) - }) - - // Check the redirect to the default app works - cy.request({ url: '/', followRedirect: false }).then((response) => { - expect(response.status).to.eq(302) - expect(response).to.have.property('headers') - expect(response.headers.location).to.contain('/apps/files') - }) - }) - - it('Toggle the "use custom default app" switch back to reset the default apps', () => { - cy.visit('/settings/admin/theming') - cy.get('[data-cy-switch-default-app]').scrollIntoView() - - cy.get('[data-cy-switch-default-app] input').should('be.checked') - cy.get('[data-cy-switch-default-app] .checkbox-content').click() - cy.get('[data-cy-switch-default-app] input').should('be.not.checked') - }) +/** + * Intercept setting the app order as `updateAppOrder` + */ +function interceptAppOrder() { + cy.intercept('POST', '/ocs/v2.php/apps/provisioning_api/api/v1/config/users/core/apporder').as('updateAppOrder') +} - it('See the default app is changed back to default', () => { - // Check the redirect to the default app works - cy.request({ url: '/', followRedirect: false }).then((response) => { - expect(response.status).to.eq(302) - expect(response).to.have.property('headers') - expect(response.headers.location).to.contain('/apps/dashboard') - }) - }) -}) +before(() => uninstallTestApp()) describe('User theming set app order', () => { + const navigationHeader = new NavigationHeader() let user: User before(() => { @@ -126,40 +39,43 @@ describe('User theming set app order', () => { }) it('See that the dashboard app is the first one', () => { + const appOrder = ['Dashboard', 'Files'] // Check the app order settings UI - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['dashboard', 'files']) - }) + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) // Check the top app menu order - cy.get('.app-menu-main .app-menu-entry').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-app-id')).get() - expect(appIDs).to.deep.eq(['dashboard', 'files']) - }) + navigationHeader.getNavigationEntries() + .each((entry, index) => expect(entry).contain.text(appOrder[index])) }) it('Change the app order', () => { + interceptAppOrder() cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible') cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click() cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible') + cy.wait('@updateAppOrder') - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['files', 'dashboard']) - }) + const appOrder = ['Files', 'Dashboard'] + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) }) it('See the app menu order is changed', () => { cy.reload() - cy.get('.app-menu-main .app-menu-entry').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-app-id')).get() - expect(appIDs).to.deep.eq(['files', 'dashboard']) - }) + const appOrder = ['Files', 'Dashboard'] + // Check the app order settings UI + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) + + // Check the top app menu order + navigationHeader.getNavigationEntries() + .each((entry, index) => expect(entry).contain.text(appOrder[index])) }) }) describe('User theming set app order with default app', () => { + const navigationHeader = new NavigationHeader() let user: User before(() => { @@ -167,7 +83,7 @@ describe('User theming set app order with default app', () => { // install a third app installTestApp() // set files as default app - cy.runOccCommand('config:system:set --value "files" defaultapp') + cy.runOccCommand('config:system:set --value \'files\' defaultapp') // Create random user for this test cy.createRandomUser().then(($user) => { @@ -193,11 +109,11 @@ describe('User theming set app order with default app', () => { it('See the app order settings: files is the first one', () => { cy.visit('/settings/user/theming') cy.get('[data-cy-app-order]').scrollIntoView() - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - expect(elements).to.have.length(4) - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['files', 'dashboard', 'testapp1', 'testapp']) - }) + + const appOrder = ['Files', 'Dashboard', 'Test App 2', 'Test App'] + // Check the app order settings UI + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) }) it('Can not change the default app', () => { @@ -212,32 +128,31 @@ describe('User theming set app order with default app', () => { }) it('Change the order of the other apps', () => { - cy.intercept('POST', '**/apps/provisioning_api/api/v1/config/users/core/apporder').as('setAppOrder') + interceptAppOrder() // Move the testapp up twice, it should be the first one after files cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="up"]').click() - cy.wait('@setAppOrder') + cy.wait('@updateAppOrder') cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="up"]').click() - cy.wait('@setAppOrder') + cy.wait('@updateAppOrder') // Can't get up anymore, files is enforced as default app cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="up"]').should('not.be.visible') // Check the final list order - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - expect(elements).to.have.length(4) - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['files', 'testapp', 'dashboard', 'testapp1']) - }) + const appOrder = ['Files', 'Test App', 'Dashboard', 'Test App 2'] + // Check the app order settings UI + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) }) it('See the app menu order is changed', () => { cy.reload() - cy.get('.app-menu-main .app-menu-entry').then(elements => { - expect(elements).to.have.length(4) - const appIDs = elements.map((idx, el) => el.getAttribute('data-app-id')).get() - expect(appIDs).to.deep.eq(['files', 'testapp', 'dashboard', 'testapp1']) - }) + + const appOrder = ['Files', 'Test App', 'Dashboard', 'Test App 2'] + // Check the top app menu order + navigationHeader.getNavigationEntries() + .each((entry, index) => expect(entry).contain.text(appOrder[index])) }) }) @@ -264,8 +179,10 @@ describe('User theming app order list accessibility', () => { }) it('click the first button', () => { + interceptAppOrder() cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="down"]').should('be.visible').focus() cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="down"]').click() + cy.wait('@updateAppOrder') }) it('see the same app kept the focus', () => { @@ -276,8 +193,10 @@ describe('User theming app order list accessibility', () => { }) it('click the last button', () => { + interceptAppOrder() cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="up"]').should('be.visible').focus() cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="up"]').click() + cy.wait('@updateAppOrder') }) it('see the same app kept the focus', () => { @@ -289,6 +208,7 @@ describe('User theming app order list accessibility', () => { }) describe('User theming reset app order', () => { + const navigationHeader = new NavigationHeader() let user: User before(() => { @@ -310,17 +230,14 @@ describe('User theming reset app order', () => { }) it('See that the dashboard app is the first one', () => { + const appOrder = ['Dashboard', 'Files'] // Check the app order settings UI - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['dashboard', 'files']) - }) + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) // Check the top app menu order - cy.get('.app-menu-main .app-menu-entry').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-app-id')).get() - expect(appIDs).to.deep.eq(['dashboard', 'files']) - }) + navigationHeader.getNavigationEntries() + .each((entry, index) => expect(entry).contain.text(appOrder[index])) }) it('See the reset button is disabled', () => { @@ -329,15 +246,17 @@ describe('User theming reset app order', () => { }) it('Change the app order', () => { + interceptAppOrder() cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible') cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click() cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible') + cy.wait('@updateAppOrder') // Check the app order settings UI - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['files', 'dashboard']) - }) + const appOrder = ['Files', 'Dashboard'] + // Check the app order settings UI + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) }) it('See the reset button is no longer disabled', () => { @@ -346,14 +265,25 @@ describe('User theming reset app order', () => { }) it('Reset the app order', () => { + cy.intercept('GET', '/ocs/v2.php/core/navigation/apps').as('loadApps') + interceptAppOrder() cy.get('[data-test-id="btn-apporder-reset"]').click({ force: true }) + + cy.wait('@updateAppOrder') + .its('request.body') + .should('have.property', 'configValue', '[]') + cy.wait('@loadApps') }) it('See the app order is restored', () => { - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['dashboard', 'files']) - }) + const appOrder = ['Dashboard', 'Files'] + // Check the app order settings UI + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) + + // Check the top app menu order + navigationHeader.getNavigationEntries() + .each((entry, index) => expect(entry).contain.text(appOrder[index])) }) it('See the reset button is disabled again', () => { diff --git a/cypress/e2e/theming/user-background.cy.ts b/cypress/e2e/theming/user-settings_background.cy.ts index cdf3ef59f4d..8abcb5bace1 100644 --- a/cypress/e2e/theming/user-background.cy.ts +++ b/cypress/e2e/theming/user-settings_background.cy.ts @@ -1,27 +1,11 @@ /** - * @copyright Copyright (c) 2022 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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { User } from '@nextcloud/cypress' -import { defaultPrimary, defaultBackground, pickRandomColor, validateBodyThemingCss } from './themingUtils' +import { defaultPrimary, defaultBackground, validateBodyThemingCss } from './themingUtils' +import { NavigationHeader } from '../../pages/NavigationHeader' const admin = new User('admin', 'admin') @@ -80,7 +64,7 @@ describe('User select shipped backgrounds and remove background', function() { // Validate changed background and primary cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss('#a53c17', background)) + cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11')) }) it('Select a bright shipped background', function() { @@ -95,21 +79,21 @@ describe('User select shipped backgrounds and remove background', function() { // Validate changed background and primary cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss('#869171', background)) + cy.waitUntil(() => validateBodyThemingCss('#56633d', background, '#dee0d3')) }) it('Remove background', function() { - cy.intercept('*/apps/theming/background/custom').as('clearBackground') + cy.intercept('*/apps/theming/background/color').as('clearBackground') // Clear background - cy.get('[data-user-theming-background-clear]').click() + cy.get('[data-user-theming-background-color]').click() // Set the accessibility state - cy.get('[data-user-theming-background-clear]').should('have.attr', 'aria-pressed', 'true') + cy.get('[data-user-theming-background-color]').should('have.attr', 'aria-pressed', 'true') // Validate clear background cy.wait('@clearBackground') - cy.waitUntil(() => validateBodyThemingCss('#869171', null)) + cy.waitUntil(() => validateBodyThemingCss('#56633d', null, '#dee0d3')) }) }) @@ -129,18 +113,18 @@ describe('User select a custom color', function() { it('Select a custom color', function() { cy.intercept('*/apps/theming/background/color').as('setColor') - pickRandomColor() + cy.get('[data-user-theming-background-color]').click() + cy.get('.color-picker__simple-color-circle').eq(5).click() // Validate custom colour change cy.wait('@setColor') - cy.waitUntil(() => cy.window().then((win) => { - const primary = getComputedStyle(win.document.body).getPropertyValue('--color-primary') - return primary !== defaultPrimary && primary !== defaultPrimary - })) + cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#a5b872')) }) }) describe('User select a bright custom color and remove background', function() { + const navigationHeader = new NavigationHeader() + before(function() { cy.createRandomUser().then((user: User) => { cy.login(user) @@ -154,10 +138,11 @@ describe('User select a bright custom color and remove background', function() { }) it('Remove background', function() { - cy.intercept('*/apps/theming/background/custom').as('clearBackground') + cy.intercept('*/apps/theming/background/color').as('clearBackground') // Clear background - cy.get('[data-user-theming-background-clear]').click() + cy.get('[data-user-theming-background-color]').click() + cy.get('[data-user-theming-background-color]').click() // Validate clear background cy.wait('@clearBackground') @@ -168,7 +153,8 @@ describe('User select a bright custom color and remove background', function() { cy.intercept('*/apps/theming/background/color').as('setColor') // Pick one of the bright color preset - cy.contains('button', 'Change color').click() + cy.get('[data-user-theming-background-color]').scrollIntoView() + cy.get('[data-user-theming-background-color]').click() cy.get('.color-picker__simple-color-circle:eq(4)').click() // Validate custom colour change @@ -176,12 +162,12 @@ describe('User select a bright custom color and remove background', function() { }) it('See the header being inverted', function() { - cy.waitUntil(() => cy.window().then((win) => { - const firstEntry = win.document.querySelector('.app-menu-main li img') - if (!firstEntry) { - return false - } - return getComputedStyle(firstEntry).filter === 'invert(1)' + cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => { + let ret = true + el.each(function() { + ret = ret && window.getComputedStyle(this).filter === 'invert(1)' + }) + return ret })) }) @@ -194,16 +180,16 @@ describe('User select a bright custom color and remove background', function() { // Validate changed background and primary cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss('#a53c17', background)) + cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11')) }) it('See the header NOT being inverted this time', function() { - cy.waitUntil(() => cy.window().then((win) => { - const firstEntry = win.document.querySelector('.app-menu-main li') - if (!firstEntry) { - return false - } - return getComputedStyle(firstEntry).filter === 'none' + cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => { + let ret = true + el.each(function() { + ret = ret && window.getComputedStyle(this).filter === 'none' + }) + return ret })) }) }) @@ -240,15 +226,13 @@ describe('User select a custom background', function() { // Wait for background to be set cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss('#4c0c04', 'apps/theming/background?v=')) + cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', '#2f2221')) }) }) describe('User changes settings and reload the page', function() { const image = 'image.jpg' - const primaryFromImage = '#4c0c04' - - let selectedColor = '' + const colorFromImage = '#2f2221' before(function() { cy.createRandomUser().then((user: User) => { @@ -280,28 +264,39 @@ describe('User changes settings and reload the page', function() { // Wait for background to be set cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss(primaryFromImage, 'apps/theming/background?v=')) + cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', colorFromImage)) }) it('Select a custom color', function() { cy.intercept('*/apps/theming/background/color').as('setColor') - cy.contains('button', 'Change color').click() + cy.get('[data-user-theming-background-color]').click() cy.get('.color-picker__simple-color-circle:eq(5)').click() + cy.get('[data-user-theming-background-color]').click() // Validate clear background cy.wait('@setColor') - cy.waitUntil(() => cy.window().then((win) => { - selectedColor = getComputedStyle(win.document.body).getPropertyValue('--color-primary') - return selectedColor !== primaryFromImage - })) + cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#a5b872')) + }) + + it('Select a custom primary color', function() { + cy.intercept('/ocs/v2.php/apps/provisioning_api/api/v1/config/users/theming/primary_color').as('setPrimaryColor') + + cy.get('[data-user-theming-primary-color-trigger]').scrollIntoView() + cy.get('[data-user-theming-primary-color-trigger]').click() + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500) + cy.get('.color-picker__simple-color-circle').should('be.visible') + cy.get('.color-picker__simple-color-circle:eq(2)').click() + cy.get('[data-user-theming-primary-color-trigger]').click() + + // Validate clear background + cy.wait('@setPrimaryColor') + cy.waitUntil(() => validateBodyThemingCss('#c98879', null, '#a5b872')) }) it('Reload the page and validate persistent changes', function() { cy.reload() - cy.waitUntil(() => validateBodyThemingCss(selectedColor, 'apps/theming/background?v=')) - - // validate accessibility state - cy.get('[data-user-theming-background-custom]').should('have.attr', 'aria-pressed', 'true') + cy.waitUntil(() => validateBodyThemingCss('#c98879', null, '#a5b872')) }) }) |