diff options
Diffstat (limited to 'cypress/e2e')
92 files changed, 13287 insertions, 0 deletions
diff --git a/cypress/e2e/core-utils.ts b/cypress/e2e/core-utils.ts new file mode 100644 index 00000000000..4756836387a --- /dev/null +++ b/cypress/e2e/core-utils.ts @@ -0,0 +1,90 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Get the unified search modal (if open) + */ +export function getUnifiedSearchModal() { + return cy.get('#unified-search') +} + +/** + * Open the unified search modal + */ +export function openUnifiedSearch() { + cy.get('button[aria-label="Unified search"]').click({ force: true }) + // wait for it to be open + getUnifiedSearchModal().should('be.visible') +} + +/** + * Close the unified search modal + */ +export function closeUnifiedSearch() { + getUnifiedSearchModal().find('button[aria-label="Close"]').click({ force: true }) + getUnifiedSearchModal().should('not.be.visible') +} + +/** + * Get the input field of the unified search + */ +export function getUnifiedSearchInput() { + return getUnifiedSearchModal().find('[data-cy-unified-search-input]') +} + +export enum UnifiedSearchFilter { + FilterCurrentView = 'current-view', + Places = 'places', + People = 'people', + Date = 'date', +} + +/** + * Get a filter action from the unified search + * @param filter The filter to get + */ +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 new file mode 100644 index 00000000000..b0e9ab8bac1 --- /dev/null +++ b/cypress/e2e/core/header_access-levels.cy.ts @@ -0,0 +1,101 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' +import { clearState, getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils' + +const admin = new User('admin', 'admin') + +describe('Header: Ensure regular users do not have admin settings in the Settings menu', { testIsolation: true }, () => { + beforeEach(() => { + clearState() + }) + + it('Regular users can see basic items in the Settings menu', () => { + // Given I am logged in + cy.createRandomUser().then(($user) => { + cy.login($user) + cy.visit('/') + }) + // I open the settings menu + getNextcloudUserMenuToggle().click() + + getNextcloudUserMenu().find('ul').within(($el) => { + // I see the settings menu is open + cy.wrap($el).should('be.visible') + + // I see that the Settings menu has only 6 items + cy.get('li').should('have.length', 6) + // I see that the "View profile" item in the Settings menu is shown + cy.contains('li', 'View profile').should('be.visible') + // I see that the "Set status" item in the Settings menu is shown + cy.contains('li', 'Set status').should('be.visible') + // I see that the "Appearance and accessibility" item in the Settings menu is shown + cy.contains('li', 'Appearance and accessibility').should('be.visible') + // I see that the "Settings" item in the Settings menu is shown + cy.contains('li', 'Settings').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 + cy.contains('li', 'Log out').should('be.visible') + }) + }) + + it('Regular users cannot see admin-level items in the Settings menu', () => { + // Given I am logged in + cy.createRandomUser().then(($user) => { + cy.login($user) + cy.visit('/') + }) + // I open the settings menu + getNextcloudUserMenuToggle().click() + + getNextcloudUserMenu().find('ul').within(($el) => { + // I see the settings menu is open + cy.wrap($el).should('be.visible') + + // I see that the "Users" item in the Settings menu is NOT shown + cy.contains('li', 'Users').should('not.exist') + // I see that the "Administration settings" item in the Settings menu is NOT shown + cy.contains('li', 'Administration settings').should('not.exist') + cy.get('#admin_settings').should('not.exist') + }) + }) + + it('Admin users can see admin-level items in the Settings menu', () => { + // Given I am logged in + cy.login(admin) + cy.visit('/') + + // I open the settings menu + getNextcloudUserMenuToggle().click() + + getNextcloudUserMenu().find('ul').within(($el) => { + // I see the settings menu is open + cy.wrap($el).should('be.visible') + + // I see that the Settings menu has only 9 items + cy.get('li').should('have.length', 9) + // I see that the "Set status" item in the Settings menu is shown + cy.contains('li', 'View profile').should('be.visible') + // I see that the "Set status" item in the Settings menu is shown + cy.contains('li', 'Set status').should('be.visible') + // I see that the "Appearance and accessibility" item in the Settings menu is shown + cy.contains('li', 'Appearance and accessibility').should('be.visible') + // I see that the "Personal Settings" item in the Settings menu is shown + cy.contains('li', 'Personal settings').should('be.visible') + // I see that the "Administration settings" item in the Settings menu is shown + cy.contains('li', 'Administration settings').should('be.visible') + // 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', '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 + cy.contains('li', 'Log out').should('be.visible') + }) + }) +}) diff --git a/cypress/e2e/core/header_contacts-menu.cy.ts b/cypress/e2e/core/header_contacts-menu.cy.ts new file mode 100644 index 00000000000..6279b72a78d --- /dev/null +++ b/cypress/e2e/core/header_contacts-menu.cy.ts @@ -0,0 +1,137 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' +import { clearState, getNextcloudHeader } from '../../support/commonUtils' + +// eslint-disable-next-line n/no-extraneous-import +import randomString from 'crypto-random-string' + +const admin = new User('admin', 'admin') + +const getContactsMenu = () => getNextcloudHeader().find('#header-menu-contactsmenu') +const getContactsMenuToggle = () => getNextcloudHeader().find('#contactsmenu .header-menu__trigger') +const getContactsSearch = () => getContactsMenu().find('#contactsmenu__menu__search') + +describe('Header: Contacts menu', { testIsolation: true }, () => { + let user: User + + beforeEach(() => { + // clear user and group state + clearState() + // ensure the contacts menu is not restricted + cy.runOccCommand('config:app:set --value no core shareapi_restrict_user_enumeration_to_group') + // create a new user for testing the contacts + cy.createRandomUser().then(($user) => { + user = $user + }) + + // Given I am logged in as the admin + cy.login(admin) + cy.visit('/') + }) + + it('Other users are seen in the contacts menu', () => { + // When I open the Contacts menu + getContactsMenuToggle().click() + // I see that the Contacts menu is shown + getContactsMenu().should('exist') + // I see that the contact user in the Contacts menu is shown + getContactsMenu().contains('li.contact', user.userId).should('be.visible') + // I see that the contact "admin" in the Contacts menu is not shown + getContactsMenu().contains('li.contact', admin.userId).should('not.exist') + }) + + it('Just added users are seen in the contacts menu', () => { + // I create a new user + const newUserName = randomString(7) + // we can not use createRandomUser as it will invalidate the session + cy.runOccCommand(`user:add --password-from-env '${newUserName}'`, { env: { OC_PASS: '1234567' } }) + // I open the Contacts menu + getContactsMenuToggle().click() + // I see that the Contacts menu is shown + getContactsMenu().should('exist') + // I see that the contact user in the Contacts menu is shown + getContactsMenu().contains('li.contact', user.userId).should('be.visible') + // I see that the contact of the new user in the Contacts menu is shown + getContactsMenu().contains('li.contact', newUserName).should('be.visible') + // I see that the contact "admin" in the Contacts menu is not shown + getContactsMenu().contains('li.contact', admin.userId).should('not.exist') + }) + + it('Search for other users in the contacts menu', () => { + cy.createRandomUser().then((otherUser) => { + // Given I am logged in as the admin + cy.login(admin) + cy.visit('/') + + // I open the Contacts menu + getContactsMenuToggle().click() + // I see that the Contacts menu is shown + getContactsMenu().should('exist') + // I see that the contact user in the Contacts menu is shown + getContactsMenu().contains('li.contact', user.userId).should('be.visible') + // I see that the contact of the new user in the Contacts menu is shown + getContactsMenu().contains('li.contact', otherUser.userId).should('be.visible') + + // I see that the Contacts menu search input is shown + getContactsSearch().should('exist') + // I search for the otherUser + getContactsSearch().type(otherUser.userId) + // I see that the contact otherUser in the Contacts menu is shown + getContactsMenu().contains('li.contact', otherUser.userId).should('be.visible') + // I see that the contact user in the Contacts menu is not shown + getContactsMenu().contains('li.contact', user.userId).should('not.exist') + // I see that the contact "admin" in the Contacts menu is not shown + getContactsMenu().contains('li.contact', admin.userId).should('not.exist') + }) + }) + + it('Search for unknown users in the contacts menu', () => { + // I open the Contacts menu + getContactsMenuToggle().click() + // I see that the Contacts menu is shown + getContactsMenu().should('exist') + // I see that the contact user in the Contacts menu is shown + getContactsMenu().contains('li.contact', user.userId).should('be.visible') + + // I see that the Contacts menu search input is shown + getContactsSearch().should('exist') + // I search for an unknown user + getContactsSearch().type('surely-unknown-user') + // I see that the no results message in the Contacts menu is shown + getContactsMenu().find('ul li').should('have.length', 0) + // I see that the contact user in the Contacts menu is not shown + getContactsMenu().contains('li.contact', user.userId).should('not.exist') + // I see that the contact "admin" in the Contacts menu is not shown + getContactsMenu().contains('li.contact', admin.userId).should('not.exist') + }) + + it('Users from other groups are not seen in the contacts menu when autocompletion is restricted within the same group', () => { + // I enable restricting username autocompletion to groups + cy.runOccCommand('config:app:set --value yes core shareapi_restrict_user_enumeration_to_group') + // I open the Contacts menu + getContactsMenuToggle().click() + // I see that the Contacts menu is shown + getContactsMenu().should('exist') + // I see that the contact user in the Contacts menu is not shown + getContactsMenu().contains('li.contact', user.userId).should('not.exist') + // I see that the contact "admin" in the Contacts menu is not shown + getContactsMenu().contains('li.contact', admin.userId).should('not.exist') + + // I close the Contacts menu + getContactsMenuToggle().click() + // I disable restricting username autocompletion to groups + cy.runOccCommand('config:app:set --value no core shareapi_restrict_user_enumeration_to_group') + // I open the Contacts menu + getContactsMenuToggle().click() + // I see that the Contacts menu is shown + getContactsMenu().should('exist') + // I see that the contact user in the Contacts menu is shown + getContactsMenu().contains('li.contact', user.userId).should('be.visible') + // I see that the contact "admin" in the Contacts menu is not shown + getContactsMenu().contains('li.contact', admin.userId).should('not.exist') + }) +}) 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 new file mode 100644 index 00000000000..71ea341a7bf --- /dev/null +++ b/cypress/e2e/files/FilesUtils.ts @@ -0,0 +1,324 @@ +/** + * 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).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) + .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) + .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() +} +export const triggerInlineActionForFile = (filename: string, actionId: string) => { + 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, 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') + + if (dirPath === '/') { + // select home folder + cy.get('button[title="Home"]').should('be.visible').click() + // click move + cy.contains('button', 'Move').should('be.visible').click() + } else if (dirPath === '.') { + // click move + cy.contains('button', 'Copy').should('be.visible').click() + } else { + 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('be.visible').click() + } + + cy.wait('@moveFile') + }) +} + +export const copyFile = (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') + + if (dirPath === '/') { + // select home folder + cy.get('button[title="Home"]').should('be.visible').click() + // click copy + cy.contains('button', 'Copy').should('be.visible').click() + } else if (dirPath === '.') { + // click copy + cy.contains('button', 'Copy').should('be.visible').click() + } else { + const directories = dirPath.split('/') + directories.forEach((directory) => { + // select the folder + cy.get(`[data-filename="${CSS.escape(directory)}"]`).should('be.visible').click() + }) + + // click copy + cy.contains('button', `Copy to ${directories.at(-1)}`).should('be.visible').click() + } + + cy.wait('@copyFile') + }) +} + +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|public)\.php\/dav\/files\//).as('moveFile') + + 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('/') + 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('[data-cy-sidebar] .app-sidebar__close').click({ force: true }) +} + +export const clickOnBreadcrumbs = (label: string) => { + cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind') + 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 new file mode 100644 index 00000000000..d8df1938694 --- /dev/null +++ b/cypress/e2e/files/drag-n-drop.cy.ts @@ -0,0 +1,140 @@ +/** + * 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 }, () => { + beforeEach(() => { + cy.createRandomUser().then((user) => { + cy.login(user) + }) + cy.visit('/apps/files') + }) + + it('can drop a file', () => { + const dataTransfer = new DataTransfer() + dataTransfer.items.add(new File([], 'single-file.txt')) + + cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') + + // Make sure the drop notice is not visible + cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') + + // Trigger the drop notice + cy.get('main.app-content').trigger('dragover', { dataTransfer }) + cy.get('[data-cy-files-drag-drop-area]').should('be.visible') + + // Upload drop a file + cy.get('[data-cy-files-drag-drop-area]').selectFile({ + fileName: 'single-file.txt', + contents: ['hello '.repeat(1024)], + }, { action: 'drag-drop' }) + + cy.wait('@uploadFile') + + // Make sure the upload is finished + cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') + cy.get('[data-cy-upload-picker] progress').should('not.be.visible') + cy.get('@uploadFile.all').should('have.length', 1) + + getRowForFile('single-file.txt').should('be.visible') + getRowForFile('single-file.txt').find('[data-cy-files-list-row-size]').should('contain', '6 KB') + }) + + it('can drop multiple files', () => { + const dataTransfer = new DataTransfer() + dataTransfer.items.add(new File([], 'first.txt')) + dataTransfer.items.add(new File([], 'second.txt')) + + cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') + + // Make sure the drop notice is not visible + cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') + + // Trigger the drop notice + cy.get('main.app-content').trigger('dragover', { dataTransfer }) + cy.get('[data-cy-files-drag-drop-area]').should('be.visible') + + // Upload drop a file + cy.get('[data-cy-files-drag-drop-area]').selectFile([ + { + fileName: 'first.txt', + contents: ['Hello'], + }, + { + fileName: 'second.txt', + contents: ['World'], + }, + ], { action: 'drag-drop' }) + + cy.wait('@uploadFile') + + // Make sure the upload is finished + cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') + cy.get('[data-cy-upload-picker] progress').should('not.be.visible') + cy.get('@uploadFile.all').should('have.length', 2) + + getRowForFile('first.txt').should('be.visible') + getRowForFile('second.txt').should('be.visible') + }) + + it('will ignore legacy Folders', () => { + cy.window().then((win) => { + // Remove the Filesystem API to force the legacy File API + // See how cypress mocks the Filesystem API in https://github.com/cypress-io/cypress/blob/74109094a92df3bef073dda15f17194f31850d7d/packages/driver/src/cy/commands/actions/selectFile.ts#L24-L37 + Object.defineProperty(win.DataTransferItem.prototype, 'getAsEntry', { get: undefined }) + Object.defineProperty(win.DataTransferItem.prototype, 'webkitGetAsEntry', { get: undefined }) + }) + + const dataTransfer = new DataTransfer() + dataTransfer.items.add(new File([], 'first.txt')) + dataTransfer.items.add(new File([], 'second.txt')) + + // Legacy File API (not FileSystem API), will treat Folders as Files + // with empty type and empty content + dataTransfer.items.add(new File([], 'Foo', { type: 'httpd/unix-directory' })) + dataTransfer.items.add(new File([], 'Bar')) + + cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') + + // Make sure the drop notice is not visible + cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') + + // Trigger the drop notice + cy.get('main.app-content').trigger('dragover', { dataTransfer }) + cy.get('[data-cy-files-drag-drop-area]').should('be.visible') + + // Upload drop a file + cy.get('[data-cy-files-drag-drop-area]').selectFile([ + { + fileName: 'first.txt', + contents: ['Hello'], + }, + { + fileName: 'second.txt', + contents: ['World'], + }, + { + fileName: 'Foo', + contents: {}, + }, + { + fileName: 'Bar', + contents: { mimeType: 'httpd/unix-directory' }, + }, + ], { action: 'drag-drop' }) + + cy.wait('@uploadFile') + + // Make sure the upload is finished + cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') + cy.get('[data-cy-upload-picker] progress').should('not.be.visible') + cy.get('@uploadFile.all').should('have.length', 2) + + getRowForFile('first.txt').should('be.visible') + getRowForFile('second.txt').should('be.visible') + getRowForFile('Foo').should('not.exist') + getRowForFile('Bar').should('not.exist') + }) +}) 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 new file mode 100644 index 00000000000..086248eef3c --- /dev/null +++ b/cypress/e2e/files/files-copy-move.cy.ts @@ -0,0 +1,177 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getRowForFile, moveFile, copyFile, navigateToFolder } from './FilesUtils.ts' + +describe('Files: Move or copy files', { testIsolation: true }, () => { + let currentUser + beforeEach(() => { + cy.createRandomUser().then((user) => { + currentUser = user + cy.login(user) + }) + }) + afterEach(() => { + // nice to have cleanup + cy.deleteUser(currentUser) + }) + + + it('Can copy a file to new folder', () => { + // Prepare initial state + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') + .mkdir(currentUser, '/new-folder') + cy.login(currentUser) + cy.visit('/apps/files') + + copyFile('original.txt', 'new-folder') + + navigateToFolder('new-folder') + + cy.url().should('contain', 'dir=/new-folder') + getRowForFile('original.txt').should('be.visible') + getRowForFile('new-folder').should('not.exist') + }) + + it('Can move a file to new folder', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') + .mkdir(currentUser, '/new-folder') + cy.login(currentUser) + cy.visit('/apps/files') + + moveFile('original.txt', 'new-folder') + + // wait until visible again + getRowForFile('new-folder').should('be.visible') + + // original should be moved -> not exist anymore + getRowForFile('original.txt').should('not.exist') + navigateToFolder('new-folder') + + cy.url().should('contain', 'dir=/new-folder') + getRowForFile('original.txt').should('be.visible') + getRowForFile('new-folder').should('not.exist') + }) + + /** + * Test for https://github.com/nextcloud/server/issues/41768 + */ + it('Can move a file to folder with similar name', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original') + .mkdir(currentUser, '/original folder') + cy.login(currentUser) + cy.visit('/apps/files') + + moveFile('original', 'original folder') + + // wait until visible again + getRowForFile('original folder').should('be.visible') + + // original should be moved -> not exist anymore + getRowForFile('original').should('not.exist') + navigateToFolder('original folder') + + cy.url().should('contain', 'dir=/original%20folder') + getRowForFile('original').should('be.visible') + getRowForFile('original folder').should('not.exist') + }) + + it('Can move a file to its parent folder', () => { + cy.mkdir(currentUser, '/new-folder') + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/new-folder/original.txt') + cy.login(currentUser) + cy.visit('/apps/files') + + navigateToFolder('new-folder') + cy.url().should('contain', 'dir=/new-folder') + + moveFile('original.txt', '/') + + // wait until visible again + cy.get('main').contains('No files in here').should('be.visible') + + // original should be moved -> not exist anymore + getRowForFile('original.txt').should('not.exist') + + cy.visit('/apps/files') + getRowForFile('new-folder').should('be.visible') + getRowForFile('original.txt').should('be.visible') + }) + + it('Can copy a file to same folder', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') + cy.login(currentUser) + cy.visit('/apps/files') + + copyFile('original.txt', '.') + + getRowForFile('original.txt').should('be.visible') + getRowForFile('original (copy).txt').should('be.visible') + }) + + it('Can copy a file multiple times to same folder', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (copy).txt') + cy.login(currentUser) + cy.visit('/apps/files') + + copyFile('original.txt', '.') + + getRowForFile('original.txt').should('be.visible') + getRowForFile('original (copy 2).txt').should('be.visible') + }) + + /** + * Test that a copied folder with a dot will be renamed correctly ('foo.bar' -> 'foo.bar (copy)') + * Test for: https://github.com/nextcloud/server/issues/43843 + */ + it('Can copy a folder to same folder', () => { + cy.mkdir(currentUser, '/foo.bar') + cy.login(currentUser) + cy.visit('/apps/files') + + copyFile('foo.bar', '.') + + getRowForFile('foo.bar').should('be.visible') + getRowForFile('foo.bar (copy)').should('be.visible') + }) + + /** Test for https://github.com/nextcloud/server/issues/43329 */ + context('escaping file and folder names', () => { + it('Can handle files with special characters', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') + .mkdir(currentUser, '/can\'t say') + cy.login(currentUser) + cy.visit('/apps/files') + + copyFile('original.txt', 'can\'t say') + + navigateToFolder('can\'t say') + + cy.url().should('contain', 'dir=/can%27t%20say') + getRowForFile('original.txt').should('be.visible') + getRowForFile('can\'t say').should('not.exist') + }) + + /** + * If escape is set to false (required for test above) then "<a>foo" would result in "<a>foo</a>" if sanitizing is not disabled + * We should disable it as vue already escapes the text when using v-text + */ + it('does not incorrectly sanitize file names', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') + .mkdir(currentUser, '/<a href="#">foo') + cy.login(currentUser) + cy.visit('/apps/files') + + copyFile('original.txt', '<a href="#">foo') + + navigateToFolder('<a href="#">foo') + + cy.url().should('contain', 'dir=/%3Ca%20href%3D%22%23%22%3Efoo') + getRowForFile('original.txt').should('be.visible') + getRowForFile('<a href="#">foo').should('not.exist') + }) + }) +}) 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-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 new file mode 100644 index 00000000000..b363e630b44 --- /dev/null +++ b/cypress/e2e/files/files-settings.cy.ts @@ -0,0 +1,158 @@ +/** + * 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.ts' + +describe('files: Set default view', { testIsolation: true }, () => { + beforeEach(() => { + cy.createRandomUser().then(($user) => { + cy.login($user) + }) + }) + + 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 + + const setupFiles = () => cy.createRandomUser().then(($user) => { + user = $user + + cy.uploadContent(user, new Blob([]), 'text/plain', '/.file') + cy.uploadContent(user, new Blob([]), 'text/plain', '/visible-file') + cy.mkdir(user, '/.folder') + cy.login(user) + }) + + context('view: All files', { testIsolation: false }, () => { + before(setupFiles) + + it('hides dot-files by default', () => { + cy.visit('/apps/files') + + getRowForFile('visible-file').should('be.visible') + getRowForFile('.file').should('not.exist') + getRowForFile('.folder').should('not.exist') + }) + + it('can show hidden files', () => { + showHiddenFiles() + // Now the files should be visible + getRowForFile('.file').should('be.visible') + getRowForFile('.folder').should('be.visible') + }) + }) + + context('view: Personal files', { testIsolation: false }, () => { + before(setupFiles) + + it('hides dot-files by default', () => { + cy.visit('/apps/files/personal') + + getRowForFile('visible-file').should('be.visible') + getRowForFile('.file').should('not.exist') + getRowForFile('.folder').should('not.exist') + }) + + it('can show hidden files', () => { + showHiddenFiles() + // Now the files should be visible + getRowForFile('.file').should('be.visible') + getRowForFile('.folder').should('be.visible') + }) + }) + + context('view: Recent files', { testIsolation: false }, () => { + before(() => { + setupFiles().then(() => { + // also add hidden file in hidden folder + cy.uploadContent(user, new Blob([]), 'text/plain', '/.folder/other-file') + cy.login(user) + }) + }) + + it('hides dot-files by default', () => { + cy.visit('/apps/files/recent') + + getRowForFile('visible-file').should('be.visible') + getRowForFile('.file').should('not.exist') + getRowForFile('.folder').should('not.exist') + getRowForFile('other-file').should('not.exist') + }) + + it('can show hidden files', () => { + showHiddenFiles() + + getRowForFile('visible-file').should('be.visible') + // Now the files should be visible + getRowForFile('.file').should('be.visible') + getRowForFile('.folder').should('be.visible') + getRowForFile('other-file').should('be.visible') + }) + }) +}) + +/** + * 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 new file mode 100644 index 00000000000..9e726bf96e1 --- /dev/null +++ b/cypress/e2e/files/files-sorting.cy.ts @@ -0,0 +1,330 @@ +/** + * 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 + beforeEach(() => { + cy.createRandomUser().then((user) => { + currentUser = user + cy.login(user) + }) + }) + + it('Files are sorted by name ascending by default', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 first.txt') + .uploadContent(currentUser, new Blob(), 'text/plain', '/z last.txt') + .uploadContent(currentUser, new Blob(), 'text/plain', '/A.txt') + .uploadContent(currentUser, new Blob(), 'text/plain', '/Ä.txt') + .mkdir(currentUser, '/m') + .mkdir(currentUser, '/4') + 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('4') + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('m') + break + case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 first.txt') + break + case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('A.txt') + break + case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('Ä.txt') + break + case 5: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') + break + case 6: expect($row.attr('data-cy-files-list-row-name')).to.eq('z last.txt') + break + } + }) + }) + + /** + * 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') + .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 + 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 + } + }) + }) + + it('Can sort by mtime', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1.txt', Date.now() / 1000 - 86400 - 1000) + .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z.txt', Date.now() / 1000 - 86400) + .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a.txt', Date.now() / 1000 - 86400 - 500) + cy.login(currentUser) + cy.visit('/apps/files') + + // click sort button + cy.get('th').contains('button', 'Modified').click() + // sorting is set + cy.contains('th', 'Modified').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('welcome.txt') // uploaded right now + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') // fake time of yesterday + break + case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') // fake time of yesterday and few minutes + break + case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') // fake time of yesterday and ~15 minutes ago + break + } + }) + + // reverse order + cy.get('th').contains('button', 'Modified').click() + cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'descending') + cy.get('[data-cy-files-list-row]').each(($row, index) => { + switch (index) { + case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') // uploaded right now + break + case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') // fake time of yesterday + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') // fake time of yesterday and few minutes + break + case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') // fake time of yesterday and ~15 minutes ago + break + } + }) + }) + + it('Favorites are sorted first', () => { + cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1.txt', Date.now() / 1000 - 86400 - 1000) + .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z.txt', Date.now() / 1000 - 86400) + .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a.txt', Date.now() / 1000 - 86400 - 500) + .setFileAsFavorite(currentUser, '/a.txt') + cy.login(currentUser) + cy.visit('/apps/files') + + cy.log('By name - ascending') + cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'ascending') + + 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('a.txt') + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.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('z.txt') + break + } + }) + + cy.log('By name - descending') + cy.get('th').contains('button', 'Name').click() + cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'descending') + + 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('a.txt') + break + case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') + break + case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') + break + } + }) + + cy.log('By size - ascending') + cy.get('th').contains('button', 'Size').click() + cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending') + + 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('a.txt') + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.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('z.txt') + break + } + }) + + cy.log('By size - descending') + cy.get('th').contains('button', 'Size').click() + cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending') + + 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('a.txt') + break + case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') + break + case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') + break + } + }) + + cy.log('By mtime - ascending') + cy.get('th').contains('button', 'Modified').click() + cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'ascending') + + 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('a.txt') + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') + break + case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') + break + case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') + break + } + }) + + cy.log('By mtime - descending') + cy.get('th').contains('button', 'Modified').click() + cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'descending') + + 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('a.txt') + break + case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') + break + case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') + break + case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') + break + } + }) + }) + + 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 new file mode 100644 index 00000000000..a961b78e2f4 --- /dev/null +++ b/cypress/e2e/files/files-xml-regression.cy.ts @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getRowForFile, triggerActionForFile } from './FilesUtils.ts' + +/** + * This is a regression test for https://github.com/nextcloud/server/issues/43331 + * Where files with XML entities in their names were wrongly displayed and could no longer be renamed / deleted etc. + */ +describe('Files: Can handle XML entities in file names', { testIsolation: false }, () => { + before(() => { + cy.createRandomUser().then((user) => { + cy.uploadContent(user, new Blob(), 'text/plain', '/and.txt') + cy.login(user) + cy.visit('/apps/files/') + }) + }) + + it('Can reanme to a file name containing XML entities', () => { + cy.intercept('MOVE', /\/remote.php\/dav\/files\//).as('renameFile') + triggerActionForFile('and.txt', 'rename') + getRowForFile('and.txt') + .find('form[aria-label="Rename file"] input') + .type('{selectAll}&.txt{enter}') + + cy.wait('@renameFile') + getRowForFile('&.txt').should('be.visible') + }) + + it('After a reload the filename is preserved', () => { + cy.reload() + getRowForFile('&.txt').should('be.visible') + getRowForFile('&.txt').should('not.exist') + }) + + it('Can delete the file', () => { + cy.intercept('DELETE', /\/remote.php\/dav\/files\//).as('deleteFile') + triggerActionForFile('&.txt', 'delete') + cy.wait('@deleteFile') + + cy.contains('.toast-success', /Delete .* done/) + .should('be.visible') + getRowForFile('&.txt').should('not.exist') + + cy.reload() + getRowForFile('&.txt').should('not.exist') + getRowForFile('&.txt').should('not.exist') + }) +}) diff --git a/cypress/e2e/files/files.cy.ts b/cypress/e2e/files/files.cy.ts new file mode 100644 index 00000000000..efae1116d2d --- /dev/null +++ b/cypress/e2e/files/files.cy.ts @@ -0,0 +1,58 @@ +import type { User } from "@nextcloud/cypress" + +/** + * 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) => { + 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 new file mode 100644 index 00000000000..8eb4efaaec0 --- /dev/null +++ b/cypress/e2e/files/live_photos.cy.ts @@ -0,0 +1,172 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +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 user: User + let randomFileName: string + let jpgFileId: number + let movFileId: number + + beforeEach(() => { + setupLivePhotos() + .then((setupInfo) => { + user = setupInfo.user + randomFileName = setupInfo.fileName + jpgFileId = setupInfo.jpgFileId + movFileId = setupInfo.movFileId + }) + }) + + it('Only renders the .jpg file', () => { + getRowForFileId(jpgFileId).should('have.length', 1) + getRowForFileId(movFileId).should('have.length', 0) + }) + + context("'Show hidden files' is enabled", () => { + beforeEach(() => { + setShowHiddenFiles(true) + }) + + it("Shows both files when 'Show hidden files' is enabled", () => { + getRowForFileId(jpgFileId).should('have.length', 1).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}.jpg`) + getRowForFileId(movFileId).should('have.length', 1).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}.mov`) + }) + + it('Copies both files when copying the .jpg', () => { + copyFile(`${randomFileName}.jpg`, '.') + clickOnBreadcrumbs('All files') + + getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) + getRowForFile(`${randomFileName}.mov`).should('have.length', 1) + getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1) + getRowForFile(`${randomFileName} (copy).mov`).should('have.length', 1) + }) + + it('Copies both files when copying the .mov', () => { + copyFile(`${randomFileName}.mov`, '.') + clickOnBreadcrumbs('All files') + + getRowForFile(`${randomFileName}.mov`).should('have.length', 1) + getRowForFile(`${randomFileName} (copy).jpg`).should('have.length', 1) + 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') + + getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`) + getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`) + }) + + it('Moves files when moving the .mov', () => { + renameFile(`${randomFileName}.mov`, `${randomFileName}_moved.mov`) + clickOnBreadcrumbs('All files') + + getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`) + getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`) + }) + + it('Deletes files when deleting the .jpg', () => { + triggerActionForFile(`${randomFileName}.jpg`, 'delete') + clickOnBreadcrumbs('All files') + + getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) + getRowForFile(`${randomFileName}.mov`).should('have.length', 0) + + cy.visit('/apps/files/trashbin') + + getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('to.match', new RegExp(`^${randomFileName}.jpg\\.d[0-9]+$`)) + getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('to.match', new RegExp(`^${randomFileName}.mov\\.d[0-9]+$`)) + }) + + it('Block deletion when deleting the .mov', () => { + triggerActionForFile(`${randomFileName}.mov`, 'delete') + clickOnBreadcrumbs('All files') + + getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) + getRowForFile(`${randomFileName}.mov`).should('have.length', 1) + + cy.visit('/apps/files/trashbin') + + getRowForFileId(jpgFileId).should('have.length', 0) + getRowForFileId(movFileId).should('have.length', 0) + }) + + it('Restores files when restoring the .jpg', () => { + triggerActionForFile(`${randomFileName}.jpg`, 'delete') + cy.visit('/apps/files/trashbin') + triggerInlineActionForFileId(jpgFileId, 'restore') + clickOnBreadcrumbs('Deleted files') + + getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) + getRowForFile(`${randomFileName}.mov`).should('have.length', 0) + + cy.visit('/apps/files') + + getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) + getRowForFile(`${randomFileName}.mov`).should('have.length', 1) + }) + + it('Blocks restoration when restoring the .mov', () => { + triggerActionForFile(`${randomFileName}.jpg`, 'delete') + cy.visit('/apps/files/trashbin') + triggerInlineActionForFileId(movFileId, 'restore') + clickOnBreadcrumbs('Deleted files') + + getRowForFileId(jpgFileId).should('have.length', 1) + getRowForFileId(movFileId).should('have.length', 1) + + cy.visit('/apps/files') + + getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) + getRowForFile(`${randomFileName}.mov`).should('have.length', 0) + }) + }) +}) 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/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 new file mode 100644 index 00000000000..75c76b7e97c --- /dev/null +++ b/cypress/e2e/files_versions/filesVersionsUtils.ts @@ -0,0 +1,90 @@ +/** + * 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 { 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 + // within less than one second of each other. + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.uploadContent(user, new Blob(['v1'], { type: 'text/plain' }), 'text/plain', `/${fileName}`) + .wait(1100) + .uploadContent(user, new Blob(['v2'], { type: 'text/plain' }), 'text/plain', `/${fileName}`) + .wait(1100) + .uploadContent(user, new Blob(['v3'], { type: 'text/plain' }), 'text/plain', `/${fileName}`) + cy.login(user) +} + +export function openVersionsPanel(fileName: string) { + // Detect the versions list fetch + cy.intercept('PROPFIND', '**/dav/versions/*/versions/**').as('getVersions') + + // Open the versions tab + cy.window().then(win => { + win.OCA.Files.Sidebar.setActiveTab('version_vue') + win.OCA.Files.Sidebar.open(`/${fileName}`) + }) + + // Wait for the versions list to be fetched + cy.wait('@getVersions') + cy.get('#tab-version_vue').should('be.visible', { timeout: 10000 }) +} + +export function toggleVersionMenu(index: number) { + cy.get('#tab-version_vue [data-files-versions-version]') + .eq(index) + .find('button') + .click() +} + +export function triggerVersionAction(index: number, actionName: string) { + toggleVersionMenu(index) + cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).filter(':visible').click() +} + +export function nameVersion(index: number, name: string) { + cy.intercept('PROPPATCH', '**/dav/versions/*/versions/**').as('labelVersion') + triggerVersionAction(index, 'label') + cy.get(':focused').type(`${name}{enter}`) + cy.wait('@labelVersion') +} + +export function restoreVersion(index: number) { + cy.intercept('MOVE', '**/dav/versions/*/versions/**').as('restoreVersion') + triggerVersionAction(index, 'restore') + cy.wait('@restoreVersion') +} + +export function deleteVersion(index: number) { + cy.intercept('DELETE', '**/dav/versions/*/versions/**').as('deleteVersion') + triggerVersionAction(index, 'delete') + cy.wait('@deleteVersion') +} + +export function doesNotHaveAction(index: number, actionName: string) { + toggleVersionMenu(index) + cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).should('not.exist') + toggleVersionMenu(index) +} + +export function assertVersionContent(index: number, expectedContent: string) { + cy.intercept({ method: 'GET', times: 1, url: 'remote.php/**' }).as('downloadVersion') + triggerVersionAction(index, 'download') + cy.wait('@downloadVersion') + .then(({ response }) => expect(response?.body).to.equal(expectedContent)) +} + +export function setupTestSharedFileFromUser(owner: User, randomFileName: string, shareOptions: Partial<ShareSetting>) { + return cy.createRandomUser() + .then((recipient) => { + cy.login(owner) + cy.visit('/apps/files') + createShare(randomFileName, recipient.userId, shareOptions) + cy.login(recipient) + cy.visit('/apps/files') + return cy.wrap(recipient) + }) +} diff --git a/cypress/e2e/files_versions/version_creation.cy.ts b/cypress/e2e/files_versions/version_creation.cy.ts new file mode 100644 index 00000000000..a0441e96b29 --- /dev/null +++ b/cypress/e2e/files_versions/version_creation.cy.ts @@ -0,0 +1,47 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils' + +describe('Versions creation', () => { + let randomFileName = '' + + before(() => { + randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt' + + cy.createRandomUser() + .then((user) => { + uploadThreeVersions(user, randomFileName) + cy.login(user) + cy.visit('/apps/files') + openVersionsPanel(randomFileName) + }) + }) + + it('Opens the versions panel and sees the versions', () => { + cy.visit('/apps/files') + openVersionsPanel(randomFileName) + + cy.get('#tab-version_vue').within(() => { + cy.get('[data-files-versions-version]').should('have.length', 3) + cy.get('[data-files-versions-version]').eq(0).contains('Current version') + 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 new file mode 100644 index 00000000000..8c673b13d4c --- /dev/null +++ b/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts @@ -0,0 +1,102 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { assertVersionContent, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions, nameVersion } from './filesVersionsUtils' +import { clickOnBreadcrumbs, closeSidebar, copyFile, moveFile, navigateToFolder } from '../files/FilesUtils' +import type { User } from '@nextcloud/cypress' + +/** + * + * @param filePath + */ +function assertVersionsContent(filePath: string) { + const path = filePath.split('/').slice(0, -1).join('/') + + clickOnBreadcrumbs('All files') + + if (path !== '') { + navigateToFolder(path) + } + + openVersionsPanel(filePath) + + cy.get('[data-files-versions-version]').should('have.length', 3) + assertVersionContent(0, 'v3') + assertVersionContent(1, 'v2') + assertVersionContent(2, 'v1') +} + +describe('Versions cross share move and copy', () => { + let randomSharedFolderName = '' + let randomFileName = '' + let randomFilePath = '' + let alice: User + let bob: User + + before(() => { + randomSharedFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + + cy.createRandomUser() + .then((user) => { + alice = user + cy.mkdir(alice, `/${randomSharedFolderName}`) + setupTestSharedFileFromUser(alice, randomSharedFolderName, {}) + }) + .then((user) => { bob = user }) + }) + + beforeEach(() => { + randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt' + randomFilePath = `${randomSharedFolderName}/${randomFileName}` + uploadThreeVersions(alice, randomFilePath) + + cy.login(bob) + cy.visit('/apps/files') + navigateToFolder(randomSharedFolderName) + openVersionsPanel(randomFilePath) + nameVersion(2, 'v1') + closeSidebar() + }) + + it('Also moves versions when bob moves the file out of a received share', () => { + moveFile(randomFileName, '/') + assertVersionsContent(randomFileName) + // TODO: move that in assertVersionsContent when copying files keeps the versions' metadata + cy.get('[data-files-versions-version]').eq(2).contains('v1') + }) + + it('Also copies versions when bob copies the file out of a received share', () => { + copyFile(randomFileName, '/') + assertVersionsContent(randomFileName) + }) + + context('When a file is in a subfolder', () => { + let randomSubFolderName + let randomSubSubFolderName + + beforeEach(() => { + randomSubFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + randomSubSubFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + clickOnBreadcrumbs('All files') + cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}`) + cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}/${randomSubSubFolderName}`) + cy.login(bob) + navigateToFolder(randomSharedFolderName) + moveFile(randomFileName, `${randomSubFolderName}/${randomSubSubFolderName}`) + }) + + it('Also moves versions when bob moves the containing folder out of a received share', () => { + moveFile(randomSubFolderName, '/') + assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`) + // TODO: move that in assertVersionsContent when copying files keeps the versions' metadata + cy.get('[data-files-versions-version]').eq(2).contains('v1') + }) + + it('Also copies versions when bob copies the containing folder out of a received share', () => { + copyFile(randomSubFolderName, '/') + assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`) + }) + }) +}) diff --git a/cypress/e2e/files_versions/version_deletion.cy.ts b/cypress/e2e/files_versions/version_deletion.cy.ts new file mode 100644 index 00000000000..b49aa872639 --- /dev/null +++ b/cypress/e2e/files_versions/version_deletion.cy.ts @@ -0,0 +1,98 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions, deleteVersion } from './filesVersionsUtils' +import { navigateToFolder, getRowForFile } from '../files/FilesUtils' + +describe('Versions restoration', () => { + const folderName = 'shared_folder' + const randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt' + const randomFilePath = `/${folderName}/${randomFileName}` + let user: User + let versionCount = 0 + + before(() => { + cy.createRandomUser() + .then((_user) => { + user = _user + cy.mkdir(user, `/${folderName}`) + uploadThreeVersions(user, randomFilePath) + uploadThreeVersions(user, randomFilePath) + versionCount = 6 + cy.login(user) + cy.visit('/apps/files') + navigateToFolder(folderName) + openVersionsPanel(randomFilePath) + }) + }) + + it('Delete initial version', () => { + cy.get('[data-files-versions-version]').should('have.length', versionCount) + deleteVersion(2) + versionCount-- + cy.get('[data-files-versions-version]').should('have.length', versionCount) + }) + + context('Delete versions of shared file', () => { + it('Works with delete permission', () => { + setupTestSharedFileFromUser(user, folderName, { delete: true }) + navigateToFolder(folderName) + openVersionsPanel(randomFilePath) + + cy.get('[data-files-versions-version]').should('have.length', versionCount) + deleteVersion(2) + versionCount-- + cy.get('[data-files-versions-version]').should('have.length', versionCount) + }) + + it('Does not work without delete permission', () => { + setupTestSharedFileFromUser(user, folderName, { delete: false }) + navigateToFolder(folderName) + openVersionsPanel(randomFilePath) + + doesNotHaveAction(0, 'delete') + doesNotHaveAction(1, 'delete') + doesNotHaveAction(2, 'delete') + }) + + it('Does not work without delete permission through direct API access', () => { + let fileId: string|undefined + let versionId: string|undefined + + setupTestSharedFileFromUser(user, folderName, { delete: false }) + .then(recipient => { + navigateToFolder(folderName) + openVersionsPanel(randomFilePath) + + 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: 'DELETE', + url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`, + auth: { user: recipient.userId, pass: recipient.password }, + headers: { + cookie: '', + }, + failOnStatusCode: false, + }) + }).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 new file mode 100644 index 00000000000..548cb86a207 --- /dev/null +++ b/cypress/e2e/files_versions/version_download.cy.ts @@ -0,0 +1,94 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { assertVersionContent, doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils' +import type { User } from '@nextcloud/cypress' +import { getRowForFile } from '../files/FilesUtils' + +describe('Versions download', () => { + let randomFileName = '' + let user: User + + 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 + uploadThreeVersions(user, randomFileName) + cy.login(user) + cy.visit('/apps/files') + openVersionsPanel(randomFileName) + }) + }) + + 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') + assertVersionContent(2, 'v1') + }) + + context('Download versions of shared file', () => { + it('Works with download permission', () => { + setupTestSharedFileFromUser(user, randomFileName, { download: true }) + openVersionsPanel(randomFileName) + + assertVersionContent(0, 'v3') + assertVersionContent(1, 'v2') + assertVersionContent(2, 'v1') + }) + + it('Does not show action without download permission', () => { + setupTestSharedFileFromUser(user, randomFileName, { download: false }) + openVersionsPanel(randomFileName) + + 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="download"]').should('not.exist') + + doesNotHaveAction(1, 'download') + doesNotHaveAction(2, 'download') + }) + + it('Does not work without download permission through direct API access', () => { + let fileId: string|undefined + let versionId: string|undefined + + setupTestSharedFileFromUser(user, randomFileName, { download: false }) + .then((recipient) => { + openVersionsPanel(randomFileName) + + 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({ + url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`, + auth: { user: recipient.userId, pass: recipient.password }, + headers: { + cookie: '', + }, + failOnStatusCode: false, + }) + }).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 new file mode 100644 index 00000000000..118ac01532f --- /dev/null +++ b/cypress/e2e/files_versions/version_expiration.cy.ts @@ -0,0 +1,56 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { assertVersionContent, nameVersion, openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils' + +describe('Versions expiration', () => { + let randomFileName = '' + + beforeEach(() => { + randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt' + + cy.createRandomUser() + .then((user) => { + uploadThreeVersions(user, randomFileName) + cy.login(user) + cy.visit('/apps/files') + openVersionsPanel(randomFileName) + }) + }) + + it('Expire all versions', () => { + 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') + openVersionsPanel(randomFileName) + + cy.get('#tab-version_vue').within(() => { + cy.get('[data-files-versions-version]').should('have.length', 1) + cy.get('[data-files-versions-version]').eq(0).contains('Current version') + }) + + assertVersionContent(0, 'v3') + }) + + it('Expire versions v2', () => { + nameVersion(2, 'v1') + + 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') + openVersionsPanel(randomFileName) + + cy.get('#tab-version_vue').within(() => { + cy.get('[data-files-versions-version]').should('have.length', 2) + cy.get('[data-files-versions-version]').eq(0).contains('Current version') + cy.get('[data-files-versions-version]').eq(1).contains('v1') + }) + + assertVersionContent(0, 'v3') + assertVersionContent(1, 'v1') + }) +}) diff --git a/cypress/e2e/files_versions/version_naming.cy.ts b/cypress/e2e/files_versions/version_naming.cy.ts new file mode 100644 index 00000000000..ff299c53227 --- /dev/null +++ b/cypress/e2e/files_versions/version_naming.cy.ts @@ -0,0 +1,133 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { nameVersion, openVersionsPanel, uploadThreeVersions, doesNotHaveAction, setupTestSharedFileFromUser } from './filesVersionsUtils' +import { getRowForFile } from '../files/FilesUtils' + +describe('Versions naming', () => { + let randomFileName = '' + let user: User + + before(() => { + randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt' + + cy.createRandomUser() + .then((_user) => { + user = _user + uploadThreeVersions(user, randomFileName) + cy.login(user) + cy.visit('/apps/files') + openVersionsPanel(randomFileName) + }) + }) + + it('Names the versions', () => { + nameVersion(2, 'v1') + cy.get('#tab-version_vue').within(() => { + cy.get('[data-files-versions-version]').eq(2).contains('v1') + cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist') + }) + + nameVersion(1, 'v2') + cy.get('#tab-version_vue').within(() => { + cy.get('[data-files-versions-version]').eq(1).contains('v2') + }) + + nameVersion(0, 'v3') + cy.get('#tab-version_vue').within(() => { + cy.get('[data-files-versions-version]').eq(0).contains('v3 (Current version)') + }) + }) + + context('Name versions of shared file', () => { + context('with edit permission', () => { + before(() => { + setupTestSharedFileFromUser(user, randomFileName, { update: true }) + openVersionsPanel(randomFileName) + }) + + it('Names the versions', () => { + nameVersion(2, 'v1 - shared') + cy.get('#tab-version_vue').within(() => { + cy.get('[data-files-versions-version]').eq(2).contains('v1 - shared') + cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist') + }) + + nameVersion(1, 'v2 - shared') + cy.get('#tab-version_vue').within(() => { + cy.get('[data-files-versions-version]').eq(1).contains('v2 - shared') + }) + + nameVersion(0, 'v3 - shared') + cy.get('#tab-version_vue').within(() => { + cy.get('[data-files-versions-version]').eq(0).contains('v3 - shared (Current version)') + }) + }) + }) + + context('without edit permission', () => { + let recipient: User + + beforeEach(() => { + setupTestSharedFileFromUser(user, randomFileName, { update: false }) + .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') + + doesNotHaveAction(1, 'label') + doesNotHaveAction(2, 'label') + }) + + it('Does not work without update permission through direct API access', () => { + let fileId: string|undefined + let versionId: string|undefined + + 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 new file mode 100644 index 00000000000..34360808f61 --- /dev/null +++ b/cypress/e2e/files_versions/version_restoration.cy.ts @@ -0,0 +1,116 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { assertVersionContent, doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, restoreVersion, uploadThreeVersions } from './filesVersionsUtils' +import { getRowForFile } from '../files/FilesUtils' + +describe('Versions restoration', () => { + let randomFileName = '' + let user: User + + before(() => { + randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt' + + cy.createRandomUser() + .then((_user) => { + user = _user + uploadThreeVersions(user, randomFileName) + cy.login(user) + cy.visit('/apps/files') + openVersionsPanel(randomFileName) + }) + }) + + it('Current version does not have restore action', () => { + doesNotHaveAction(0, 'restore') + }) + + it('Restores initial version', () => { + restoreVersion(2) + + cy.get('#tab-version_vue').within(() => { + cy.get('[data-files-versions-version]').should('have.length', 3) + cy.get('[data-files-versions-version]').eq(0).contains('Current version') + cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist') + }) + }) + + it('Downloads versions and assert there content', () => { + assertVersionContent(0, 'v1') + assertVersionContent(1, 'v3') + assertVersionContent(2, 'v2') + }) + + context('Restore versions of shared file', () => { + it('Works with update permission', () => { + setupTestSharedFileFromUser(user, randomFileName, { update: true }) + openVersionsPanel(randomFileName) + + it('Restores initial version', () => { + restoreVersion(2) + cy.get('#tab-version_vue').within(() => { + cy.get('[data-files-versions-version]').should('have.length', 3) + cy.get('[data-files-versions-version]').eq(0).contains('Current version') + cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist') + }) + }) + + it('Downloads versions and assert there content', () => { + assertVersionContent(0, 'v1') + assertVersionContent(1, 'v3') + assertVersionContent(2, 'v2') + }) + }) + + it('Does not show action without delete permission', () => { + setupTestSharedFileFromUser(user, randomFileName, { update: false }) + openVersionsPanel(randomFileName) + + 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="restore"]').should('not.exist') + + doesNotHaveAction(1, 'restore') + doesNotHaveAction(2, 'restore') + }) + + it('Does not work without update permission through direct API access', () => { + let fileId: string|undefined + let versionId: string|undefined + + setupTestSharedFileFromUser(user, randomFileName, { update: false }) + .then((recipient) => { + openVersionsPanel(randomFileName) + + 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: 'MOVE', + url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`, + auth: { user: recipient.userId, pass: recipient.password }, + headers: { + cookie: '', + Destination: `${base}}/remote.php/dav/versions/${recipient.userId}/restore/target`, + }, + failOnStatusCode: false, + }) + }).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 new file mode 100644 index 00000000000..97e3b9a24bf --- /dev/null +++ b/cypress/e2e/login/login.cy.ts @@ -0,0 +1,152 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils' + +describe('Login', () => { + let user: User + let disabledUser: User + + after(() => cy.deleteUser(user)) + before(() => { + // disable brute force protection + cy.runOccCommand('config:system:set auth.bruteforce.protection.enabled --value false --type bool') + cy.createRandomUser().then(($user) => { + user = $user + }) + cy.createRandomUser().then(($user) => { + disabledUser = $user + cy.runOccCommand(`user:disable '${disabledUser.userId}'`) + }) + }) + + beforeEach(() => { + cy.logout() + }) + + it('log in with valid account and password', () => { + // Given I visit the Home page + cy.visit('/') + // I see the login page + cy.get('form[name="login"]').should('be.visible') + // I log in with a valid account + 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 the login is done + cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') + + // Then I see that the current page is the Files app + cy.url().should('match', /apps\/dashboard(\/|$)/) + }) + + it('try to log in with valid account and invalid password', () => { + // Given I visit the Home page + cy.visit('/') + // I see the login page + cy.get('form[name="login"]').should('be.visible') + // I log in with a valid account but invalid password + cy.get('form[name="login"]').within(() => { + cy.get('input[name="user"]').type(user.userId) + cy.get('input[name="password"]').type(`${user.password}--wrong`) + cy.contains('button', 'Log in').click() + }) + + // see that the login is done + cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') + + // Then I see that the current page is the Login page + cy.url().should('match', /\/login/) + // And I see that a wrong password message is shown + cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i)) + cy.get('input[name="password"]:invalid').should('exist') + }) + + it('try to log in with valid account and invalid password', () => { + // Given I visit the Home page + cy.visit('/') + // I see the login page + cy.get('form[name="login"]').should('be.visible') + // I log in with a valid account but invalid password + cy.get('form[name="login"]').within(() => { + cy.get('input[name="user"]').type(user.userId) + cy.get('input[name="password"]').type(`${user.password}--wrong`) + cy.contains('button', 'Log in').click() + }) + + // see that the login is done + cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') + + // Then I see that the current page is the Login page + cy.url().should('match', /\/login/) + // And I see that a wrong password message is shown + cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i).and.to.match(/Wrong.+login/)) + cy.get('input[name="password"]:invalid').should('exist') + }) + + it('try to log in with invalid account', () => { + // Given I visit the Home page + cy.visit('/') + // I see the login page + cy.get('form[name="login"]').should('be.visible') + // I log in with an invalid user but valid password + cy.get('form[name="login"]').within(() => { + cy.get('input[name="user"]').type(`${user.userId}--wrong`) + cy.get('input[name="password"]').type(user.password) + cy.contains('button', 'Log in').click() + }) + + // see that the login is done + cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') + + // Then I see that the current page is the Login page + cy.url().should('match', /\/login/) + // And I see that a wrong password message is shown + cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i).and.to.match(/Wrong.+login/)) + cy.get('input[name="password"]:invalid').should('exist') + }) + + it('try to log in as disabled account', () => { + // Given I visit the Home page + cy.visit('/') + // I see the login page + cy.get('form[name="login"]').should('be.visible') + // When I log in with user disabledUser and password + cy.get('form[name="login"]').within(() => { + cy.get('input[name="user"]').type(disabledUser.userId) + cy.get('input[name="password"]').type(disabledUser.password) + cy.contains('button', 'Log in').click() + }) + + // see that the login is done + cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') + + // Then I see that the current page is the Login page + cy.url().should('match', /\/login/) + // And I see that the disabled account message is shown + cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Account.+disabled/i)) + cy.get('input[name="password"]:invalid').should('exist') + }) + + it('try to logout', () => { + cy.login(user) + + // Given I visit the Home page + cy.visit('/') + // I see the dashboard + cy.url().should('match', /apps\/dashboard(\/|$)/) + + // When click logout + getNextcloudUserMenuToggle().should('exist').click() + getNextcloudUserMenu().contains('a', 'Log out').click() + + // Then I see that the current page is the Login page + cy.url().should('match', /\/login/) + }) +}) 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 new file mode 100644 index 00000000000..4bf0cbc1832 --- /dev/null +++ b/cypress/e2e/settings/access-levels.cy.ts @@ -0,0 +1,65 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' +import { clearState, getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils' + +const admin = new User('admin', 'admin') + +describe('Settings: Ensure only administrator can see the administration settings section', { testIsolation: true }, () => { + beforeEach(() => { + clearState() + }) + + it('Regular users cannot see admin-level items on the Settings page', () => { + // Given I am logged in + cy.createRandomUser().then(($user) => { + cy.login($user) + cy.visit('/') + }) + + // I open the settings menu + getNextcloudUserMenuToggle().click() + // I navigate to the settings panel + getNextcloudUserMenu() + .findByRole('link', { name: /settings/i }) + .click() + cy.url().should('match', /\/settings\/user$/) + + cy.get('#app-navigation').should('be.visible').within(() => { + // I see the personal section is NOT shown + cy.get('#app-navigation-caption-personal').should('not.exist') + // I see the admin section is NOT shown + cy.get('#app-navigation-caption-administration').should('not.exist') + + // I see that the "Personal info" entry in the settings panel is shown + cy.get('[data-section-id="personal-info"]').should('exist').and('be.visible') + }) + }) + + it('Admin users can see admin-level items on the Settings page', () => { + // Given I am logged in + cy.login(admin) + cy.visit('/') + + // I open the settings menu + getNextcloudUserMenuToggle().click() + // I navigate to the settings panel + getNextcloudUserMenu() + .findByRole('link', { name: /Personal settings/i }) + .click() + cy.url().should('match', /\/settings\/user$/) + + cy.get('#app-navigation').should('be.visible').within(() => { + // I see the personal section is shown + cy.get('#app-navigation-caption-personal').should('be.visible') + // I see the admin section is shown + cy.get('#app-navigation-caption-administration').should('be.visible') + + // I see that the "Personal info" entry in the settings panel is shown + cy.get('[data-section-id="personal-info"]').should('exist').and('be.visible') + }) + }) +}) diff --git a/cypress/e2e/settings/apps.cy.ts b/cypress/e2e/settings/apps.cy.ts new file mode 100644 index 00000000000..0df073271ef --- /dev/null +++ b/cypress/e2e/settings/apps.cy.ts @@ -0,0 +1,156 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' +import { handlePasswordConfirmation } from './usersUtils' + +const admin = new User('admin', 'admin') + +describe('Settings: App management', { testIsolation: true }, () => { + beforeEach(() => { + // disable QA if already enabled + cy.runOccCommand('app:disable -n testing') + // enable notification if already disabled + cy.runOccCommand('app:enable -n updatenotification') + + // 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', () => { + cy.get('#apps-list').should('exist') + // Wait for the app list to load + .contains('tr', 'QA testing', { timeout: 10000 }) + .should('exist') + // I enable the "QA testing" app + .contains('button', 'Enable') + .click({ force: true }) + + handlePasswordConfirmation(admin.password) + + // Wait until we see the disable button for the app + cy.get('#apps-list').should('exist') + .contains('tr', 'QA testing') + .should('exist') + // I see the disable button for the app + .contains('button', 'Disable', { timeout: 10000 }) + + // Change to enabled apps view + cy.get('#app-category-enabled a').click({ force: true }) + cy.url().should('match', /settings\/apps\/enabled$/) + // I see that the "QA testing" app has been enabled + cy.get('#apps-list').contains('tr', 'QA testing') + }) + + it('Can disable an installed app', () => { + cy.get('#apps-list') + .should('exist') + // Wait for the app list to load + .contains('tr', 'Update notification', { timeout: 10000 }) + .should('exist') + // I disable the "Update notification" app + .contains('button', 'Disable') + .click({ force: true }) + + handlePasswordConfirmation(admin.password) + + // Wait until we see the disable button for the app + cy.get('#apps-list').should('exist') + .contains('tr', 'Update notification') + .should('exist') + // I see the enable button for the app + .contains('button', 'Enable', { timeout: 10000 }) + + // Change to disabled apps view + cy.get('#app-category-disabled a').click({ force: true }) + cy.url().should('match', /settings\/apps\/disabled$/) + // I see that the "Update notification" app has been disabled + cy.get('#apps-list').contains('tr', 'Update notification') + }) + + it('Browse enabled apps', () => { + // When I open the "Active apps" section + cy.get('#app-category-enabled a') + .should('contain', 'Active apps') + .click({ force: true }) + // Then I see that the current section is "Active apps" + cy.url().should('match', /settings\/apps\/enabled$/) + cy.get('#app-category-enabled').find('.active').should('exist') + // I see that there are only enabled apps + cy.get('#apps-list') + .should('exist') + .find('tr button') + .each(($action) => { + cy.wrap($action).should('not.contain', 'Enable') + }) + }) + + it('Browse disabled apps', () => { + // When I open the "Active apps" section + cy.get('#app-category-disabled a') + .should('contain', 'Disabled apps') + .click({ force: true }) + // Then I see that the current section is "Active apps" + cy.url().should('match', /settings\/apps\/disabled$/) + cy.get('#app-category-disabled').find('.active').should('exist') + // I see that there are only disabled apps + cy.get('#apps-list') + .should('exist') + .find('tr button') + .each(($action) => { + cy.wrap($action).should('not.contain', 'Disable') + }) + }) + + it('Browse app bundles', () => { + // When I open the "App bundles" section + cy.get('#app-category-your-bundles a') + .should('contain', 'App bundles') + .click({ force: true }) + // Then I see that the current section is "App bundles" + cy.url().should('match', /settings\/apps\/app-bundles$/) + 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 bundle') + // I see that the "Enterprise bundle" is disabled + cy.get('#apps-list').contains('tr', 'Enterprise bundle').contains('button', 'Download and enable all') + }) + + it('View app details', () => { + // When I click on the "QA testing" app + cy.get('#apps-list').contains('a', 'QA testing').click({ force: true }) + // I see that the app details are shown + cy.get('#app-sidebar-vue') + .should('be.visible') + .find('.app-sidebar-header__info') + .should('contain', 'QA testing') + cy.get('#app-sidebar-vue').contains('a', 'View in store').should('exist') + cy.get('#app-sidebar-vue').find('input[type="button"][value="Enable"]').should('be.visible') + cy.get('#app-sidebar-vue').find('input[type="button"][value="Remove"]').should('be.visible') + cy.get('#app-sidebar-vue').contains(/Version \d+\.\d+\.\d+/).should('be.visible') + }) + + /* + * TODO: Improve testing with app store as external API + * The following scenarios require the files_antivirus and calendar app + * being present in the app store with support for the current server version + * Ideally we would have either a dummy app store endpoint with some test apps + * or even an app store instance running somewhere to properly test this. + * This is also a requirement to properly test updates of apps + */ + // TODO: View app details for app store apps + // TODO: Install an app from the app store + // TODO: Show section from app store +}) diff --git a/cypress/e2e/settings/personal-info.cy.ts b/cypress/e2e/settings/personal-info.cy.ts new file mode 100644 index 00000000000..8d4b4bb606a --- /dev/null +++ b/cypress/e2e/settings/personal-info.cy.ts @@ -0,0 +1,448 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' +import { handlePasswordConfirmation } from './usersUtils.ts' + +let user: User + +enum Visibility { + Private = 'Private', + Local = 'Local', + Federated = 'Federated', + Public = 'Published' +} + +const ALL_VISIBILITIES = [Visibility.Public, Visibility.Private, Visibility.Local, Visibility.Federated] + +/** + * Get the input connected to a specific label + * @param label The content of the label + */ +const inputForLabel = (label: string) => cy.contains('label', label).then((el) => cy.get(`#${el.attr('for')}`)) + +/** + * Get the property visibility button + * @param property The property to which to look for the button + */ +const getVisibilityButton = (property: string) => cy.get(`button[aria-label*="Change scope level of ${property.toLowerCase()}"`) + +/** + * Validate a specifiy visibility is set for a property + * @param property The property + * @param active The active visibility + */ +const validateActiveVisibility = (property: string, active: Visibility) => { + getVisibilityButton(property) + .should('have.attr', 'aria-label') + .and('match', new RegExp(`current scope is ${active}`, 'i')) + getVisibilityButton(property) + .click() + cy.get('ul[role="menu"]') + .contains('button', active) + .should('have.attr', 'aria-checked', 'true') + + // close menu + getVisibilityButton(property) + .click() +} + +/** + * Set a specific visibility for a property + * @param property The property + * @param active The visibility to set + */ +const setActiveVisibility = (property: string, active: Visibility) => { + getVisibilityButton(property) + .click() + cy.get('ul[role="menu"]') + .contains('button', active) + .click({ force: true }) + handlePasswordConfirmation(user.password) + + cy.wait('@submitSetting') +} + +/** + * Helper to check that setting all visibilities on a property is possible + * @param property The property to test + * @param defaultVisibility The default visibility of that property + * @param allowedVisibility Visibility that is allowed and need to be checked + */ +const checkSettingsVisibility = (property: string, defaultVisibility: Visibility = Visibility.Local, allowedVisibility: Visibility[] = ALL_VISIBILITIES) => { + getVisibilityButton(property) + .scrollIntoView() + + validateActiveVisibility(property, defaultVisibility) + + allowedVisibility.forEach((active) => { + setActiveVisibility(property, active) + + cy.reload() + getVisibilityButton(property).scrollIntoView() + + validateActiveVisibility(property, active) + }) + + // TODO: Fix this in vue library then enable this test again + /* // Test that not allowed options are disabled + ALL_VISIBILITIES.filter((v) => !allowedVisibility.includes(v)).forEach((disabled) => { + getVisibilityButton(property) + .click() + cy.get('ul[role="dialog"') + .contains('button', disabled) + .should('exist') + .and('have.attr', 'disabled', 'true') + }) */ +} + +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.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') + + 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') + + 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') + }) + + it('Can change language', () => { + cy.intercept('GET', /settings\/user/).as('reload') + inputForLabel('Language').scrollIntoView() + inputForLabel('Language').type('Ned') + cy.contains('li[role="option"]', 'Nederlands') + .click() + cy.wait('@reload') + + // expect language changed + inputForLabel('Taal').scrollIntoView() + cy.contains('section', 'Help met vertalen') + }) + + it('Can change locale', () => { + cy.intercept('GET', /settings\/user/).as('reload') + cy.clock(new Date(2024, 0, 10)) + + // Default is US + cy.contains('section', '01/10/2024') + + inputForLabel('Locale').scrollIntoView() + inputForLabel('Locale').type('German') + cy.contains('li[role="option"]', 'German (Germany') + .click() + cy.wait('@reload') + + // expect locale changed + inputForLabel('Locale').scrollIntoView() + cy.contains('section', '10.01.2024') + }) + + it('Can set primary email and change its visibility', () => { + cy.contains('label', 'Email').scrollIntoView() + // Check invalid input + inputForLabel('Email').type('foo bar') + inputForLabel('Email').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false) + // handle valid input + inputForLabel('Email').type('{selectAll}hello@example.com') + handlePasswordConfirmation(user.password) + + cy.wait('@submitSetting') + cy.reload() + inputForLabel('Email').should('have.value', 'hello@example.com') + + checkSettingsVisibility( + 'Email', + Visibility.Federated, + // It is not possible to set it as private + ALL_VISIBILITIES.filter((v) => v !== Visibility.Private), + ) + + // check it is visible on the profile + cy.visit(`/u/${user.userId}`) + cy.contains('a', 'hello@example.com').should('be.visible').and('have.attr', 'href', 'mailto:hello@example.com') + }) + + it('Can delete primary email', () => { + cy.contains('label', 'Email').scrollIntoView() + inputForLabel('Email').type('{selectAll}hello@example.com') + handlePasswordConfirmation(user.password) + cy.wait('@submitSetting') + + // check after reload + cy.reload() + inputForLabel('Email').should('have.value', 'hello@example.com') + + // delete email + cy.get('button[aria-label="Remove primary email"]').click({ force: true }) + cy.wait('@submitSetting') + + // check after reload + cy.reload() + inputForLabel('Email').should('have.value', '') + }) + + it('Can set and delete additional emails', () => { + cy.get('button[aria-label="Add additional email"]').should('be.disabled') + // we need a primary email first + cy.contains('label', 'Email').scrollIntoView() + inputForLabel('Email').type('{selectAll}primary@example.com') + handlePasswordConfirmation(user.password) + cy.wait('@submitSetting') + + // add new email + cy.get('button[aria-label="Add additional email"]') + .click() + + // without any value we should not be able to add a second additional + cy.get('button[aria-label="Add additional email"]').should('be.disabled') + + // fill the first additional + inputForLabel('Additional email address 1') + .type('1@example.com') + handlePasswordConfirmation(user.password) + cy.wait('@submitSetting') + + // add second additional email + cy.get('button[aria-label="Add additional email"]') + .click() + + // fill the second additional + inputForLabel('Additional email address 2') + .type('2@example.com') + handlePasswordConfirmation(user.password) + cy.wait('@submitSetting') + + // check the content is saved + cy.reload() + inputForLabel('Additional email address 1') + .should('have.value', '1@example.com') + inputForLabel('Additional email address 2') + .should('have.value', '2@example.com') + + // delete the first + cy.get('button[aria-label="Options for additional email address 1"]') + .click({ force: true }) + cy.contains('button[role="menuitem"]', 'Delete email') + .click({ force: true }) + handlePasswordConfirmation(user.password) + + cy.reload() + inputForLabel('Additional email address 1') + .should('have.value', '2@example.com') + }) + + it('Can set Full name and change its visibility', () => { + cy.contains('label', 'Full name').scrollIntoView() + // handle valid input + inputForLabel('Full name').type('{selectAll}Jane Doe') + handlePasswordConfirmation(user.password) + + cy.wait('@submitSetting') + cy.reload() + inputForLabel('Full name').should('have.value', 'Jane Doe') + + checkSettingsVisibility( + 'Full name', + Visibility.Federated, + // It is not possible to set it as private + ALL_VISIBILITIES.filter((v) => v !== Visibility.Private), + ) + + // check it is visible on the profile + cy.visit(`/u/${user.userId}`) + cy.contains('h2', 'Jane Doe').should('be.visible') + }) + + it('Can set Phone number and its visibility', () => { + cy.contains('label', 'Phone number').scrollIntoView() + // Check invalid input + inputForLabel('Phone number').type('foo bar') + inputForLabel('Phone number').should('have.attr', 'class').and('contain', '--error') + // handle valid input + inputForLabel('Phone number').type('{selectAll}+49 89 721010 99701') + inputForLabel('Phone number').should('have.attr', 'class').and('not.contain', '--error') + handlePasswordConfirmation(user.password) + + cy.wait('@submitSetting') + cy.reload() + inputForLabel('Phone number').should('have.value', '+498972101099701') + + checkSettingsVisibility('Phone number') + + // check it is visible on the profile + cy.visit(`/u/${user.userId}`) + 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 + inputForLabel('Website').type('foo bar') + inputForLabel('Website').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false) + // handle valid input + inputForLabel('Website').type('{selectAll}http://example.com') + handlePasswordConfirmation(user.password) + + cy.wait('@submitSetting') + cy.reload() + inputForLabel('Website').should('have.value', 'http://example.com') + + checkSettingsVisibility('Website') + + // check it is visible on the profile + cy.visit(`/u/${user.userId}`) + cy.contains('http://example.com').should('be.visible') + }) + + // Check generic properties that allow any visibility and any value + genericProperties.forEach(([property, value]) => { + it(`Can set ${property} and change its visibility`, () => { + cy.contains('label', property).scrollIntoView() + inputForLabel(property).type(value) + handlePasswordConfirmation(user.password) + + cy.wait('@submitSetting') + cy.reload() + inputForLabel(property).should('have.value', value) + + checkSettingsVisibility(property) + + // check it is visible on the profile + cy.visit(`/u/${user.userId}`) + cy.contains(value).should('be.visible') + }) + }) + + // Check non federated properties - those where we need special configuration and only support local visibility + nonfederatedProperties.forEach((property) => { + it(`Can set ${property} and change its visibility`, () => { + const uniqueValue = `${property.toUpperCase()} ${property.toLowerCase()}` + cy.contains('label', property).scrollIntoView() + inputForLabel(property).type(uniqueValue) + handlePasswordConfirmation(user.password) + + cy.wait('@submitSetting') + cy.reload() + inputForLabel(property).should('have.value', uniqueValue) + + checkSettingsVisibility(property, Visibility.Local, [Visibility.Private, Visibility.Local]) + + // check it is visible on the profile + cy.visit(`/u/${user.userId}`) + cy.contains(uniqueValue).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 new file mode 100644 index 00000000000..5b8726e92ca --- /dev/null +++ b/cypress/e2e/settings/users.cy.ts @@ -0,0 +1,129 @@ +/** + * 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' +import { getUserListRow, handlePasswordConfirmation } from './usersUtils' + +const admin = new User('admin', 'admin') +const john = new User('john', '123456') + +describe('Settings: Create and delete accounts', function() { + beforeEach(function() { + cy.listUsers().then((users) => { + if ((users as string[]).includes(john.userId)) { + // ensure created user is deleted + cy.deleteUser(john) + } + }) + cy.login(admin) + // open the User settings + cy.visit('/settings/users') + }) + + it('Can create a user', function() { + // open the New user modal + cy.get('button#new-user-button').click() + + cy.get('form[data-test="form"]').within(() => { + // 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 user with additional field data', function() { + // open the New user modal + cy.get('button#new-user-button').click() + + cy.get('form[data-test="form"]').within(() => { + // set the username + cy.get('input[data-test="username"]').should('exist').and('have.value', '') + cy.get('input[data-test="username"]').type(john.userId) + cy.get('input[data-test="username"]').should('have.value', john.userId) + // set the display name + cy.get('input[data-test="displayName"]').should('exist').and('have.value', '') + cy.get('input[data-test="displayName"]').type('John Smith') + cy.get('input[data-test="displayName"]').should('have.value', 'John Smith') + // set the email + cy.get('input[data-test="email"]').should('exist').and('have.value', '') + cy.get('input[data-test="email"]').type('john@example.org') + cy.get('input[data-test="email"]').should('have.value', 'john@example.org') + // set the password + 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 }) + }) + + // 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 delete a user', function() { + let testUser + // create user + cy.createRandomUser() + .then(($user) => { + testUser = $user + }) + cy.login(admin) + // ensure created user is present + cy.reload().then(() => { + // see that the user is in the list + getUserListRow(testUser.userId).within(() => { + // see that the list of users contains the user testUser + cy.contains(testUser.userId).should('exist') + // open the actions menu for the user + cy.get('[data-cy-user-list-cell-actions]') + .find('button.action-item__menutoggle') + .click({ force: true }) + }) + + // 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 }) + + // 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 new file mode 100644 index 00000000000..7d8ea55d35b --- /dev/null +++ b/cypress/e2e/settings/usersUtils.ts @@ -0,0 +1,90 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/cypress' + +/** + * Assert that `element` does not exist or is not visible + * Useful in cases such as when NcModal is opened/closed rapidly + * @param element Element that is inspected + */ +export function assertNotExistOrNotVisible(element: JQuery<HTMLElement>) { + const doesNotExist = element.length === 0 + const isNotVisible = !element.is(':visible') + + // eslint-disable-next-line no-unused-expressions + expect(doesNotExist || isNotVisible, 'does not exist or is not visible').to.be.true +} + +/** + * Get the settings users list + * @return Cypress chainable object + */ +export function getUserList() { + return cy.get('[data-cy-user-list]') +} + +/** + * Get the row entry for given userId within the settings users list + * + * @param userId the user to query + * @return Cypress chainable object + */ +export function getUserListRow(userId: string) { + return getUserList().find(`[data-cy-user-row="${userId}"]`) +} + +export function waitLoading(selector: string) { + // We need to make sure the element is loading, otherwise the "done loading" will succeed even if we did not start loading. + // But Cypress might also be simply too slow to catch the loading phase. Thats why we need to wait in this case. + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.get(`${selector}[data-loading]`).if().should('exist').else().wait(1000) + // https://github.com/NoriSte/cypress-wait-until/issues/75#issuecomment-572685623 + cy.waitUntil(() => Cypress.$(selector).length > 0 && !Cypress.$(selector).attr('data-loading')?.length, { timeout: 10000 }) +} + +/** + * Toggle the edit button of the user row + * @param user The user row to edit + * @param toEdit True if it should be switch to edit mode, false to switch to read-only + */ +export function toggleEditButton(user: User, toEdit = true) { + // see that the list of users contains the user + getUserListRow(user.userId).should('exist') + // toggle the edit mode for the user + .find('[data-cy-user-list-cell-actions]') + .find(`[data-cy-user-list-action-toggle-edit="${!toEdit}"]`) + .if() + .click({ force: true }) + .else() + // otherwise ensure the button is already in edit mode + .then(() => getUserListRow(user.userId) + .find(`[data-cy-user-list-action-toggle-edit="${toEdit}"]`) + .should('exist'), + ) +} + +/** + * Handle the confirm password dialog (if needed) + * @param adminPassword The admin password for the dialog + */ +export function handlePasswordConfirmation(adminPassword = 'admin') { + const handleModal = (context: Cypress.Chainable) => { + return context.contains('.modal-container', 'Confirm your password') + .if() + .within(() => { + cy.get('input[type="password"]').type(adminPassword) + cy.get('button').contains('Confirm').click() + }) + } + + return cy.get('body') + .if() + .then(() => handleModal(cy.get('body'))) + .else() + // Handle if inside a cy.within + .root().closest('body') + .then(($body) => handleModal(cy.wrap($body))) +} diff --git a/cypress/e2e/settings/users_columns.cy.ts b/cypress/e2e/settings/users_columns.cy.ts new file mode 100644 index 00000000000..0afbf14e773 --- /dev/null +++ b/cypress/e2e/settings/users_columns.cy.ts @@ -0,0 +1,94 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' +import { assertNotExistOrNotVisible, getUserList } from './usersUtils.js' + +const admin = new User('admin', 'admin') + +describe('Settings: Show and hide columns', function() { + before(function() { + cy.login(admin) + // open the User settings + cy.visit('/settings/users') + }) + + beforeEach(function() { + // open the settings dialog + cy.contains('button', 'Account management settings').click() + // reset all visibility toggles + cy.get('.modal-container #settings-section_visibility-settings input[type="checkbox"]').uncheck({ force: true }) + + cy.contains('.modal-container', 'Account management settings').within(() => { + // enable the last login toggle + cy.get('[data-test="showLastLogin"] input[type="checkbox"]').check({ force: true }) + // close the settings dialog + cy.get('button.modal-container__close').click() + }) + cy.waitUntil(() => cy.get('.modal-container').should(el => assertNotExistOrNotVisible(el))) + }) + + it('Can show a column', function() { + // see that the language column is not in the header + cy.get('[data-cy-user-list-header-languages]').should('not.exist') + + // see that the language column is not in all user rows + cy.get('tbody.user-list__body tr').each(($row) => { + cy.wrap($row).get('[data-test="language"]').should('not.exist') + }) + + // open the settings dialog + cy.contains('button', 'Account management settings').click() + + cy.contains('.modal-container', 'Account management settings').within(() => { + // enable the language toggle + cy.get('[data-test="showLanguages"] input[type="checkbox"]').should('not.be.checked') + cy.get('[data-test="showLanguages"] input[type="checkbox"]').check({ force: true }) + cy.get('[data-test="showLanguages"] input[type="checkbox"]').should('be.checked') + // close the settings dialog + cy.get('button.modal-container__close').click() + }) + cy.waitUntil(() => cy.get('.modal-container').should(el => assertNotExistOrNotVisible(el))) + + // see that the language column is in the header + cy.get('[data-cy-user-list-header-languages]').should('exist') + + // see that the language column is in all user rows + getUserList().find('tbody tr').each(($row) => { + cy.wrap($row).get('[data-cy-user-list-cell-language]').should('exist') + }) + }) + + it('Can hide a column', function() { + // see that the last login column is in the header + cy.get('[data-cy-user-list-header-last-login]').should('exist') + + // see that the last login column is in all user rows + getUserList().find('tbody tr').each(($row) => { + cy.wrap($row).get('[data-cy-user-list-cell-last-login]').should('exist') + }) + + // open the settings dialog + cy.contains('button', 'Account management settings').click() + + cy.contains('.modal-container', 'Account management settings').within(() => { + // disable the last login toggle + cy.get('[data-test="showLastLogin"] input[type="checkbox"]').should('be.checked') + cy.get('[data-test="showLastLogin"] input[type="checkbox"]').uncheck({ force: true }) + cy.get('[data-test="showLastLogin"] input[type="checkbox"]').should('not.be.checked') + // close the settings dialog + cy.get('button.modal-container__close').click() + }) + cy.waitUntil(() => cy.contains('.modal-container', 'Account management settings').should(el => assertNotExistOrNotVisible(el))) + + // see that the last login column is not in the header + cy.get('[data-cy-user-list-header-last-login]').should('not.exist') + + // see that the last login column is not in all user rows + getUserList().find('tbody tr').each(($row) => { + cy.wrap($row).get('[data-cy-user-list-cell-last-login]').should('not.exist') + }) + }) +}) diff --git a/cypress/e2e/settings/users_disable.cy.ts b/cypress/e2e/settings/users_disable.cy.ts new file mode 100644 index 00000000000..6195d43e211 --- /dev/null +++ b/cypress/e2e/settings/users_disable.cy.ts @@ -0,0 +1,79 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' +import { getUserListRow } from './usersUtils' +import { clearState } from '../../support/commonUtils' + +const admin = new User('admin', 'admin') + +describe('Settings: Disable and enable users', function() { + let testUser: User + + beforeEach(function() { + clearState() + cy.createRandomUser().then(($user) => { + testUser = $user + }) + cy.login(admin) + // open the User settings + cy.visit('/settings/users') + }) + + // Not guranteed to run but would be nice to cleanup + after(() => { + cy.deleteUser(testUser) + }) + + it('Can disable the user', function() { + // ensure user is enabled + cy.enableUser(testUser) + + // see that the user is in the list of active users + getUserListRow(testUser.userId).within(() => { + // see that the list of users contains the user testUser + cy.contains(testUser.userId).should('exist') + // open the actions menu for the user + cy.get('[data-cy-user-list-cell-actions] button.action-item__menutoggle').click({ scrollBehavior: 'center' }) + }) + + // The "Disable account" action in the actions menu is shown and clicked + cy.get('.action-item__popper .action').contains('Disable account').should('exist').click() + // When clicked the section is not shown anymore + getUserListRow(testUser.userId).should('not.exist') + // But the disabled user section now exists + cy.get('#disabled').should('exist') + // Open disabled users section + cy.get('#disabled a').click() + cy.url().should('match', /\/disabled/) + // The list of disabled users should now contain the user + getUserListRow(testUser.userId).should('exist') + }) + + it('Can enable the user', function() { + // ensure user is disabled + cy.enableUser(testUser, false).reload() + + // Open disabled users section + cy.get('#disabled a').click() + cy.url().should('match', /\/disabled/) + + // see that the user is in the list of active users + getUserListRow(testUser.userId).within(() => { + // see that the list of disabled users contains the user testUser + cy.contains(testUser.userId).should('exist') + // open the actions menu for the user + cy.get('[data-cy-user-list-cell-actions] button.action-item__menutoggle').click({ scrollBehavior: 'center' }) + }) + + // The "Enable account" action in the actions menu is shown and clicked + cy.get('.action-item__popper .action').contains('Enable account').should('exist').click() + // When clicked the section is not shown anymore + cy.get('#disabled').should('not.exist') + // Make sure it is still gone after the reload reload + cy.reload().login(admin) + cy.get('#disabled').should('not.exist') + }) +}) diff --git a/cypress/e2e/settings/users_groups.cy.ts b/cypress/e2e/settings/users_groups.cy.ts new file mode 100644 index 00000000000..8d84ddc6bb4 --- /dev/null +++ b/cypress/e2e/settings/users_groups.cy.ts @@ -0,0 +1,291 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' +import { assertNotExistOrNotVisible, getUserListRow, handlePasswordConfirmation, toggleEditButton } from './usersUtils' + +// eslint-disable-next-line n/no-extraneous-import +import randomString from 'crypto-random-string' + +const admin = new User('admin', 'admin') + +describe('Settings: Create groups', () => { + before(() => { + cy.login(admin) + cy.visit('/settings/users') + }) + + it('Can create a group', () => { + const groupName = randomString(7) + // open the Create group menu + cy.get('button[aria-label="Create group"]').click() + + cy.get('li[data-cy-users-settings-new-group-name]').within(() => { + // see that the group name is "" + cy.get('input').should('exist').and('have.value', '') + // set the group name to foo + cy.get('input').type(groupName) + // see that the group name is foo + cy.get('input').should('have.value', groupName) + // submit the group name + cy.get('input ~ button').click() + }) + + // Make sure no confirmation modal is shown + handlePasswordConfirmation(admin.password) + + // see that the created 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') + }) + }) +}) + +describe('Settings: Assign user to a group', { testIsolation: false }, () => { + const groupName = randomString(7) + let testUser: User + + after(() => cy.deleteUser(testUser)) + before(() => { + cy.createRandomUser().then((user) => { + testUser = user + }) + 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"]').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', () => { + getUserListRow(testUser.userId) + .contains(testUser.userId) + .should('exist') + .scrollIntoView() + }) + + it('switch into user edit mode', () => { + toggleEditButton(testUser) + getUserListRow(testUser.userId) + .find('[data-cy-user-list-input-groups]') + .should('exist') + }) + + it('assign the group', () => { + // focus inside the input + getUserListRow(testUser.userId) + .find('[data-cy-user-list-input-groups] input') + .click({ force: true }) + // enter the group name + getUserListRow(testUser.userId) + .find('[data-cy-user-list-input-groups] input') + .type(`${groupName.slice(0, 5)}`) // only type part as otherwise we would create a new one with the same name + cy.contains('li.vs__dropdown-option', groupName) + .click({ force: true }) + + handlePasswordConfirmation(admin.password) + }) + + it('leave the user edit mode', () => { + toggleEditButton(testUser, false) + }) + + it('see the group was successfully assigned', () => { + // see a new memeber + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) + .find('.counter-bubble__counter') + .should('contain', '1') + }) + + it('validate the user was added on backend', () => { + cy.runOccCommand(`user:info --output=json '${testUser.userId}'`).then((output) => { + cy.wrap(output.code).should('eq', 0) + cy.wrap(JSON.parse(output.stdout)?.groups).should('include', groupName) + }) + }) +}) + +describe('Settings: Delete an empty group', { testIsolation: false }, () => { + const groupName = randomString(7) + + 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', () => { + // 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 "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 }) + + // Make sure no confirmation modal is shown + handlePasswordConfirmation(admin.password) + }) + + it('deleted group is not shown anymore', () => { + // 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)) + expect(groups).to.not.include(groupName) + }) + }) +}) + +describe('Settings: Delete a non empty group', () => { + let testUser: User + const groupName = randomString(7) + + before(() => { + cy.runOccCommand(`group:add '${groupName}'`) + cy.createRandomUser().then(($user) => { + testUser = $user + 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"]').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"]').find('li').contains(groupName) + .find('button.action-item__menutoggle') + .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 }) + + // Make sure no confirmation modal is shown + handlePasswordConfirmation(admin.password) + }) + + it('deleted group is not shown anymore', () => { + // 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)) + expect(groups).to.not.include(groupName) + }) + }) +}) + +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}'`) + }) + }) + + // Add two groups and add one user to group B + cy.runOccCommand('group:add A') + cy.runOccCommand('group:add B') + cy.createRandomUser().then((user) => { + cy.runOccCommand(`group:adduser B '${user.userId}'`) + }) + + // Visit the settings as admin + cy.login(admin) + cy.visit('/settings/users') + }) + + it('Can set sort by member count', () => { + // open the settings dialog + cy.contains('button', 'Account management settings').click() + + cy.contains('.modal-container', 'Account management settings').within(() => { + cy.get('[data-test="sortGroupsByMemberCount"] input[type="radio"]').scrollIntoView() + cy.get('[data-test="sortGroupsByMemberCount"] input[type="radio"]').check({ force: true }) + // close the settings dialog + cy.get('button.modal-container__close').click() + }) + cy.waitUntil(() => cy.get('.modal-container').should(el => assertNotExistOrNotVisible(el))) + }) + + it('See that the groups are sorted by the member count', () => { + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => { + cy.get('li').eq(0).should('contain', 'B') // 1 member + cy.get('li').eq(1).should('contain', 'A') // 0 members + }) + }) + + it('See that the order is preserved after a reload', () => { + cy.reload() + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => { + cy.get('li').eq(0).should('contain', 'B') // 1 member + cy.get('li').eq(1).should('contain', 'A') // 0 members + }) + }) + + it('Can set sort by group name', () => { + // open the settings dialog + cy.contains('button', 'Account management settings').click() + + cy.contains('.modal-container', 'Account management settings').within(() => { + cy.get('[data-test="sortGroupsByName"] input[type="radio"]').scrollIntoView() + cy.get('[data-test="sortGroupsByName"] input[type="radio"]').check({ force: true }) + // close the settings dialog + cy.get('button.modal-container__close').click() + }) + cy.waitUntil(() => cy.get('.modal-container').should(el => assertNotExistOrNotVisible(el))) + }) + + it('See that the groups are sorted by the user count', () => { + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => { + cy.get('li').eq(0).should('contain', 'A') + cy.get('li').eq(1).should('contain', 'B') + }) + }) + + it('See that the order is preserved after a reload', () => { + cy.reload() + cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => { + cy.get('li').eq(0).should('contain', 'A') + cy.get('li').eq(1).should('contain', 'B') + }) + }) +}) 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 new file mode 100644 index 00000000000..749bded2e94 --- /dev/null +++ b/cypress/e2e/settings/users_modify.cy.ts @@ -0,0 +1,225 @@ +/** + * SPDX-FileCopyrightText: 2023 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: Change user properties', function() { + let user: User + + beforeEach(function() { + clearState() + cy.createRandomUser().then(($user) => { user = $user }) + cy.login(admin) + }) + + it('Can change the display name', function() { + // open the User settings as admin + cy.visit('/settings/users') + + // toggle edit button into edit mode + toggleEditButton(user, true) + + getUserListRow(user.userId).within(() => { + // set the display name + cy.get('[data-cy-user-list-input-displayname]').should('exist').and('have.value', user.userId) + cy.get('[data-cy-user-list-input-displayname]').clear() + cy.get('[data-cy-user-list-input-displayname]').type('John Doe') + cy.get('[data-cy-user-list-input-displayname]').should('have.value', 'John Doe') + cy.get('[data-cy-user-list-input-displayname] ~ button').click() + + // Make sure no confirmation modal is shown + handlePasswordConfirmation(admin.password) + + // see that the display name cell is done loading + waitLoading('[data-cy-user-list-input-displayname]') + }) + + // Success message is shown + cy.get('.toastify.toast-success').contains(/Display.+name.+was.+successfully.+changed/i).should('exist') + }) + + it('Can change the password', function() { + // open the User settings as admin + cy.visit('/settings/users') + + // toggle edit button into edit mode + toggleEditButton(user, true) + + getUserListRow(user.userId).within(() => { + // see that the password of user is "" + cy.get('[data-cy-user-list-input-password]').should('exist').and('have.value', '') + // set the password for user to 123456 + cy.get('[data-cy-user-list-input-password]').type('123456') + // When I set the password for user to 123456 + cy.get('[data-cy-user-list-input-password]').should('have.value', '123456') + cy.get('[data-cy-user-list-input-password] ~ button').click() + + // Make sure no confirmation modal is shown + handlePasswordConfirmation(admin.password) + + // see that the password cell for user is done loading + waitLoading('[data-cy-user-list-input-password]') + // password input is emptied on change + cy.get('[data-cy-user-list-input-password]').should('have.value', '') + }) + + // Success message is shown + cy.get('.toastify.toast-success').contains(/Password.+successfully.+changed/i).should('exist') + }) + + it('Can change the email address', function() { + // open the User settings as admin + cy.visit('/settings/users') + + // toggle edit button into edit mode + toggleEditButton(user, true) + + getUserListRow(user.userId).find('[data-cy-user-list-cell-email]').within(() => { + // see that the email of user is "" + cy.get('input').should('exist').and('have.value', '') + // set the email for user to mymail@example.com + cy.get('input').type('mymail@example.com') + // When I set the password for user to mymail@example.com + cy.get('input').should('have.value', 'mymail@example.com') + cy.get('input ~ button').click() + + // Make sure no confirmation modal is shown + handlePasswordConfirmation(admin.password) + + // see that the password cell for user is done loading + waitLoading('[data-cy-user-list-input-email]') + }) + + // Success message is shown + cy.get('.toastify.toast-success').contains(/Email.+successfully.+changed/i).should('exist') + }) + + it('Can change the user quota to a predefined one', function() { + // open the User settings as admin + cy.visit('/settings/users') + + // toggle edit button into edit mode + toggleEditButton(user, true) + + getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').scrollIntoView() + getUserListRow(user.userId).find('[data-cy-user-list-cell-quota] [data-cy-user-list-input-quota]').within(() => { + // see that the quota of user is unlimited + cy.get('.vs__selected').should('exist').and('contain.text', 'Unlimited') + // Open the quota selector + cy.get('[role="combobox"]').click({ force: true }) + // see that there are default options for the quota + cy.get('li').then(($options) => { + expect($options).to.have.length(5) + cy.wrap($options).contains('Default quota') + cy.wrap($options).contains('Unlimited') + cy.wrap($options).contains('1 GB') + cy.wrap($options).contains('10 GB') + // select 5 GB + cy.wrap($options).contains('5 GB').click({ force: true }) + + // Make sure no confirmation modal is shown + handlePasswordConfirmation(admin.password) + }) + // see that the quota of user is 5 GB + cy.get('.vs__selected').should('exist').and('contain.text', '5 GB') + }) + + // see that the changes are loading + waitLoading('[data-cy-user-list-input-quota]') + + // finish editing the user + toggleEditButton(user, false) + + // I see that the quota was set on the backend + cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => { + expect($result.code).to.equal(0) + const info = JSON.parse($result.stdout) + expect(info?.quota).to.equal('5 GB') + }) + }) + + it('Can change the user quota to a custom value', function() { + // open the User settings as admin + cy.visit('/settings/users') + + // toggle edit button into edit mode + toggleEditButton(user, true) + + getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').scrollIntoView() + getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').within(() => { + // see that the quota of user is unlimited + cy.get('.vs__selected').should('exist').and('contain.text', 'Unlimited') + // set the quota to 4 MB + cy.get('[data-cy-user-list-input-quota] input').type('4 MB{enter}') + + // Make sure no confirmation modal is shown + handlePasswordConfirmation(admin.password) + + // see that the quota of user is 4 MB + // TODO: Enable this after the file size handling is fixed + // cy.get('.vs__selected').should('exist').and('contain.text', '4 MB') + + // see that the changes are loading + waitLoading('[data-cy-user-list-input-quota]') + }) + + // finish editing the user + toggleEditButton(user, false) + + // I see that the quota was set on the backend + cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => { + expect($result.code).to.equal(0) + // TODO: Enable this after the file size handling is fixed!!!!!! + // const info = JSON.parse($result.stdout) + // expect(info?.quota).to.equal('4 MB') + }) + }) + + it('Can make user a subadmin of a group', function() { + // create a group + const groupName = 'userstestgroup' + cy.runOccCommand(`group:add '${groupName}'`) + + // open the User settings as admin + cy.visit('/settings/users') + + // toggle edit button into edit mode + toggleEditButton(user, true) + + getUserListRow(user.userId).find('[data-cy-user-list-cell-subadmins]').scrollIntoView() + getUserListRow(user.userId).find('[data-cy-user-list-cell-subadmins]').within(() => { + // see that the user is no subadmin + 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 }) + + // handle password confirmation on time out + handlePasswordConfirmation(admin.password) + + // see that the user is subadmin of the group + cy.get('.vs__selected').should('exist').and('contain.text', groupName) + }) + + waitLoading('[data-cy-user-list-input-subadmins]') + + // finish editing the user + toggleEditButton(user, false) + + // I see that the quota was set on the backend + cy.getUserData(user).then(($response) => { + expect($response.status).to.equal(200) + const dom = (new DOMParser()).parseFromString($response.body, 'text/xml') + expect(dom.querySelector('subadmin element')?.textContent).to.contain(groupName) + }) + }) +}) diff --git a/cypress/e2e/systemtags/admin-settings.cy.ts b/cypress/e2e/systemtags/admin-settings.cy.ts new file mode 100644 index 00000000000..ac85cf34d65 --- /dev/null +++ b/cypress/e2e/systemtags/admin-settings.cy.ts @@ -0,0 +1,121 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' + +const admin = new User('admin', 'admin') + +const tagName = 'foo' +const updatedTagName = 'bar' + +describe('Create system tags', () => { + before(() => { + cy.login(admin) + cy.visit('/settings/admin') + }) + + it('Can create a tag', () => { + cy.get('input#system-tag-name').should('exist').and('have.value', '') + cy.get('input#system-tag-name').type(tagName) + cy.get('input#system-tag-name').should('have.value', tagName) + // submit the form + cy.get('input#system-tag-name').type('{enter}') + + // see that the created tag is in the list + cy.get('input#system-tags-input').focus() + cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => { + cy.get(`ul#${id}`).within(() => { + cy.contains('li', tagName).should('exist') + // ensure only one tag exists + cy.get('li').should('have.length', 1) + }) + }) + }) +}) + +describe('Update system tags', { testIsolation: false }, () => { + before(() => { + cy.login(admin) + cy.visit('/settings/admin') + }) + + it('select the tag', () => { + // select the tag to edit + cy.get('input#system-tags-input').focus() + cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => { + cy.get(`ul#${id}`).within(() => { + cy.contains('li', tagName).should('exist').click() + }) + }) + // see that the tag name matches the selected tag + cy.get('input#system-tag-name').should('exist').and('have.value', tagName) + // see that the tag level matches the selected tag + cy.get('input#system-tag-level').click() + cy.get('input#system-tag-level').siblings('.vs__selected').contains('Public').should('exist') + }) + + it('update the tag name and level', () => { + cy.get('input#system-tag-name').clear() + cy.get('input#system-tag-name').type(updatedTagName) + cy.get('input#system-tag-name').should('have.value', updatedTagName) + // select the new tag level + cy.get('input#system-tag-level').focus() + cy.get('input#system-tag-level').invoke('attr', 'aria-controls').then(id => { + cy.get(`ul#${id}`).within(() => { + cy.contains('li', 'Invisible').should('exist').click() + }) + }) + // submit the form + cy.get('input#system-tag-name').type('{enter}') + }) + + it('see the tag was successfully updated', () => { + cy.get('input#system-tags-input').focus() + cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => { + cy.get(`ul#${id}`).within(() => { + cy.contains('li', `${updatedTagName} (invisible)`).should('exist') + // ensure only one tag exists + cy.get('li').should('have.length', 1) + }) + }) + }) +}) + +describe('Delete system tags', { testIsolation: false }, () => { + before(() => { + cy.login(admin) + cy.visit('/settings/admin') + }) + + it('select the tag', () => { + // select the tag to edit + cy.get('input#system-tags-input').focus() + cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => { + cy.get(`ul#${id}`).within(() => { + cy.contains('li', `${updatedTagName} (invisible)`).should('exist').click() + }) + }) + // see that the tag name matches the selected tag + cy.get('input#system-tag-name').should('exist').and('have.value', updatedTagName) + // see that the tag level matches the selected tag + cy.get('input#system-tag-level').focus() + cy.get('input#system-tag-level').siblings('.vs__selected').contains('Invisible').should('exist') + }) + + it('can delete the tag', () => { + cy.get('.system-tag-form__row').within(() => { + cy.contains('button', 'Delete').should('be.enabled').click() + }) + }) + + it('see that the deleted tag is not present', () => { + cy.get('input#system-tags-input').focus() + cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => { + cy.get(`ul#${id}`).within(() => { + cy.contains('li', updatedTagName).should('not.exist') + }) + }) + }) +}) 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 new file mode 100644 index 00000000000..bff7df28e8e --- /dev/null +++ b/cypress/e2e/theming/a11y-color-contrast.cy.ts @@ -0,0 +1,157 @@ +/** + * 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 = { + 'Main text': { + foregroundColors: [ + 'color-main-text', + // 'color-text-light', deprecated + // 'color-text-lighter', deprecated + 'color-text-maxcontrast', + ], + backgroundColors: [ + 'color-main-background', + 'color-background-hover', + 'color-background-dark', + // 'color-background-darker', this should only be used for elements not for text + ], + }, + 'blurred background': { + foregroundColors: [ + 'color-main-text', + 'color-text-maxcontrast-blur', + ], + backgroundColors: [ + 'color-main-background-blur', + ], + }, + Primary: { + foregroundColors: [ + 'color-primary-text', + ], + backgroundColors: [ + // 'color-primary-default', this should only be used for elements not for text! + // 'color-primary-hover', this should only be used for elements and not for text! + 'color-primary', + ], + }, + 'Primary light': { + foregroundColors: [ + 'color-primary-light-text', + ], + backgroundColors: [ + 'color-primary-light', + 'color-primary-light-hover', + ], + }, + 'Primary element': { + foregroundColors: [ + 'color-primary-element-text', + 'color-primary-element-text-dark', + ], + backgroundColors: [ + 'color-primary-element', + 'color-primary-element-hover', + ], + }, + 'Primary element light': { + foregroundColors: [ + 'color-primary-element-light-text', + ], + backgroundColors: [ + 'color-primary-element-light', + 'color-primary-element-light-hover', + ], + }, + 'Servity information texts': { + foregroundColors: [ + 'color-error-text', + 'color-warning-text', + 'color-success-text', + 'color-info-text', + ], + backgroundColors: [ + 'color-main-background', + 'color-background-hover', + 'color-main-background-blur', + ], + }, +} + +/** + * Create a wrapper element with color and background set + * + * @param foreground The foreground color (css variable without leading --) + * @param background The background color + */ +function createTestCase(foreground: string, background: string) { + const wrapper = document.createElement('div') + wrapper.style.padding = '14px' + wrapper.style.color = `var(--${foreground})` + wrapper.style.backgroundColor = `var(--${background})` + if (background.includes('blur')) { + wrapper.style.backdropFilter = 'var(--filter-background-blur)' + } + + const testCase = document.createElement('div') + testCase.innerText = `${foreground} ${background}` + testCase.setAttribute('data-cy-testcase', '') + + wrapper.appendChild(testCase) + return wrapper +} + +describe('Accessibility of Nextcloud theming colors', () => { + for (const theme of themesToTest) { + context(`Theme: ${theme}`, () => { + before(() => { + cy.createRandomUser().then(($user) => { + // set user 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' }) + }) + }) + + beforeEach(() => { + cy.document().then(doc => { + // 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('#content') + if (root === null) { + throw new Error('No test root found') + } + root.innerHTML = '' + }) + }) + + for (const [name, { backgroundColors, foregroundColors }] of Object.entries(testCases)) { + context(`Accessibility of CSS color variables for ${name}`, () => { + for (const foreground of foregroundColors) { + for (const background of backgroundColors) { + it(`color contrast of ${foreground} on ${background}`, () => { + cy.document().then(doc => { + const element = createTestCase(foreground, background) + 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 + root!.appendChild(element) + + cy.checkA11y('[data-cy-testcase]', { + runOnly: ['color-contrast'], + }) + }) + }) + } + } + }) + } + }) + } +}) diff --git a/cypress/e2e/theming/admin-settings.cy.ts b/cypress/e2e/theming/admin-settings.cy.ts new file mode 100644 index 00000000000..4207b98f711 --- /dev/null +++ b/cypress/e2e/theming/admin-settings.cy.ts @@ -0,0 +1,595 @@ +/** + * 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 { + defaultPrimary, + defaultBackground, + pickRandomColor, + validateBodyThemingCss, + validateUserThemingDefaultCss, + expectBackgroundColor, +} from './themingUtils' +import { NavigationHeader } from '../../pages/NavigationHeader' + +const admin = new User('admin', 'admin') + +describe('Admin theming settings visibility check', function() { + before(function() { + // Just in case previous test failed + cy.resetAdminTheming() + cy.login(admin) + }) + + 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('be.visible') + }) + + it('See the default settings', function() { + 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('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)) + }) +}) + +describe('Change the primary color and reset it', function() { + let selectedColor = '' + + before(function() { + // Just in case previous test failed + cy.resetAdminTheming() + cy.login(admin) + }) + + 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('be.visible') + }) + + it('Change the primary color', function() { + cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') + + pickRandomColor('[data-admin-theming-setting-primary-color]').then( + (color) => { + selectedColor = color + }, + ) + + cy.wait('@setColor') + 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, + defaultPrimary, + ), + ) + cy.screenshot() + }) + + it('Undo theming settings and validate login page again', function() { + cy.resetAdminTheming() + cy.visit('/') + + cy.waitUntil(validateBodyThemingCss) + cy.screenshot() + }) +}) + +describe('Remove the default background and restore it', function() { + before(function() { + // Just in case previous test failed + cy.resetAdminTheming() + cy.login(admin) + }) + + 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('be.visible') + }) + + it('Remove the default background', function() { + 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') + return backgroundPlain !== '' + }), + ) + }) + + it('Screenshot the login page and validate login page', function() { + cy.logout() + cy.visit('/') + + cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null)) + cy.screenshot() + }) + + it('Undo theming settings and validate login page again', function() { + cy.resetAdminTheming() + cy.visit('/') + + cy.waitUntil(validateBodyThemingCss) + cy.screenshot() + }) +}) + +describe('Remove the default background with a custom background color', function() { + let selectedColor = '' + + before(function() { + // Just in case previous test failed + cy.resetAdminTheming() + cy.login(admin) + }) + + 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('be.visible') + }) + + 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, + defaultBackground, + selectedColor, + ), + ) + }) + + it('Remove the default background', function() { + cy.intercept('*/apps/theming/ajax/updateStylesheet').as( + 'removeBackground', + ) + + cy.get('[data-admin-theming-setting-file-remove]').scrollIntoView() + cy.get('[data-admin-theming-setting-file-remove]').click({ + force: true, + }) + + cy.wait('@removeBackground') + }) + + it('Screenshot the login page and validate login page', function() { + cy.logout() + cy.visit('/') + + cy.waitUntil(() => + validateBodyThemingCss(defaultPrimary, null, selectedColor), + ) + cy.screenshot() + }) + + it('Undo theming settings and validate login page again', function() { + cy.resetAdminTheming() + cy.visit('/') + + cy.waitUntil(validateBodyThemingCss) + cy.screenshot() + }) +}) + +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() + cy.resetUserTheming(admin) + cy.login(admin) + }) + + 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('be.visible') + }) + + it('Remove the default background', function() { + cy.intercept('*/apps/theming/ajax/updateStylesheet').as( + 'removeBackground', + ) + + cy.get('[data-admin-theming-setting-file-remove]').click() + + cy.wait('@removeBackground') + }) + + it('Change the background color', function() { + cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') + + // Pick one of the bright color preset + pickRandomColor( + '[data-admin-theming-setting-background-color]', + 4, + ).then((color) => { + selectedColor = color + }) + + cy.wait('@setColor') + cy.waitUntil(() => + validateBodyThemingCss(defaultPrimary, null, selectedColor), + ) + }) + + it('See the header being inverted', function() { + cy.waitUntil(() => + navigationHeader + .getNavigationEntries() + .find('img') + .then((el) => { + let ret = true + el.each(function() { + ret = ret && window.getComputedStyle(this).filter === 'invert(1)' + }) + return ret + }) + ) + }) +}) + +describe('Change the login fields then reset them', function() { + const name = 'ABCdef123' + const url = 'https://example.com' + const slogan = 'Testing is fun' + + before(function() { + // Just in case previous test failed + cy.resetAdminTheming() + cy.login(admin) + }) + + 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('be.visible') + }) + + it('Change the name field', 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.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.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.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') + }) + + it('Validate login screen changes', function() { + cy.logout() + cy.visit('/') + + cy.get('[data-login-form-headline]').should('contain.text', name) + cy.get('footer p a').should('have.text', name) + cy.get('footer p a').should('have.attr', 'href', url) + cy.get('footer p').should('contain.text', `– ${slogan}`) + }) + + it('Undo theming settings', function() { + cy.resetAdminTheming() + }) + + it('Validate login screen changes again', function() { + cy.visit('/') + + cy.get('[data-login-form-headline]').should('not.contain.text', name) + cy.get('footer p a').should('not.have.text', name) + cy.get('footer p a').should('not.have.attr', 'href', url) + cy.get('footer p').should('not.contain.text', `– ${slogan}`) + }) +}) + +describe('Disable user theming and enable it back', function() { + before(function() { + // Just in case previous test failed + cy.resetAdminTheming() + cy.login(admin) + }) + + 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('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.wait('@disableUserTheming') + }) + + it('Login as user', function() { + cy.logout() + cy.createRandomUser().then((user) => { + cy.login(user) + }) + }) + + it('User cannot not change background settings', function() { + cy.visit('/settings/user/theming') + cy.contains( + 'Customization has been disabled by your administrator', + ).should('exist') + }) +}) + +describe('The user default background settings reflect the admin theming settings', function() { + let selectedColor = '' + + before(function() { + // Just in case previous test failed + cy.resetAdminTheming() + cy.login(admin) + }) + + after(function() { + cy.resetAdminTheming() + }) + + 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('be.visible') + }) + + 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.wait('@setBackground') + 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( + defaultPrimary, + '/apps/theming/image/background?v=', + selectedColor, + ), + ) + }) + + it('Login as user', function() { + cy.createRandomUser().then((user) => { + cy.login(user) + }) + }) + + it('See the user background settings', function() { + cy.visit('/settings/user/theming') + cy.get('[data-user-theming-background-settings]').scrollIntoView() + cy.get('[data-user-theming-background-settings]').should('be.visible') + }) + + 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=', + ), + ) + }) +}) + +describe('The user default background settings reflect the admin theming settings with background removed', function() { + before(function() { + // Just in case previous test failed + cy.resetAdminTheming() + cy.login(admin) + }) + + after(function() { + cy.resetAdminTheming() + }) + + 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('be.visible') + }) + + it('Remove the default background', function() { + 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)) + }) + + it('Login page should match admin theming settings', function() { + cy.logout() + cy.visit('/') + + cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null)) + }) + + it('Login as user', function() { + cy.createRandomUser().then((user) => { + cy.login(user) + }) + }) + + it('See the user background settings', function() { + cy.visit('/settings/user/theming') + cy.get('[data-user-theming-background-settings]').scrollIntoView() + cy.get('[data-user-theming-background-settings]').should('be.visible') + }) + + 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(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 new file mode 100644 index 00000000000..b4740beda1c --- /dev/null +++ b/cypress/e2e/theming/themingUtils.ts @@ -0,0 +1,109 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { colord } from 'colord' + +export const defaultPrimary = '#00679e' +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 primary color + * @param {string|null} expectedBackground the expected background + * @param {string|null} expectedBackgroundColor the expected background color (null to ignore) + */ +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 = expectedBackgroundColor === null || colord(guestBackgroundColor).isEqual(expectedBackgroundColor) + const isValidBackgroundImage = !expectedBackground + ? guestBackgroundImage === 'none' + : guestBackgroundImage.includes(expectedBackground) + + console.debug({ + isValidBackgroundColor, + isValidBackgroundImage, + guestBackgroundColor: colord(guestBackgroundColor).toHex(), + guestBackgroundImage, + }) + + 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()) +} + +/** + * Validate the user theming default select option css + * + * @param {string} expectedColor the expected color + * @param {string} expectedBackground the expected background + */ +export const validateUserThemingDefaultCss = function(expectedColor = defaultPrimary, expectedBackground: string|null = defaultBackground) { + const defaultSelectButton = Cypress.$('[data-user-theming-background-default]') + if (defaultSelectButton.length === 0) { + return false + } + + const backgroundImage = defaultSelectButton.css('background-image') + const backgroundColor = defaultSelectButton.css('background-color') + + const isValidBackgroundImage = !expectedBackground + ? (backgroundImage === 'none' || Cypress.$('body').css('background-image') === 'none') + : backgroundImage.includes(expectedBackground) + + console.debug({ + colorPickerOptionColor: colord(backgroundColor).toHex(), + expectedColor, + isValidBackgroundImage, + backgroundImage, + }) + + return isValidBackgroundImage && colord(backgroundColor).isEqual(expectedColor) +} + +export const pickRandomColor = function(context: string, index?: number): Cypress.Chainable<string> { + // Pick one of the first 8 options + const randColour = index ?? Math.floor(Math.random() * 8) + + const colorPreviewSelector = `${context} [data-admin-theming-setting-color]` + + let oldColor = '' + cy.get(colorPreviewSelector).then(($el) => { + oldColor = $el.css('background-color') + }) + + // Open picker + 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() + + // Wait for color change + cy.waitUntil(() => Cypress.$(colorPreviewSelector).css('background-color') !== oldColor) + + // Get the selected color from the color preview block + return cy.get(colorPreviewSelector).then(($el) => $el.css('background-color')) +} diff --git a/cypress/e2e/theming/user-settings_app-order.cy.ts b/cypress/e2e/theming/user-settings_app-order.cy.ts new file mode 100644 index 00000000000..11ef2f45382 --- /dev/null +++ b/cypress/e2e/theming/user-settings_app-order.cy.ts @@ -0,0 +1,292 @@ +/** + * 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' + +/** + * 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') +} + +before(() => uninstallTestApp()) + +describe('User theming set app order', () => { + const navigationHeader = new NavigationHeader() + let user: User + + before(() => { + cy.resetAdminTheming() + // Create random user for this test + cy.createRandomUser().then(($user) => { + user = $user + cy.login($user) + }) + }) + + after(() => cy.deleteUser(user)) + + it('See the app order settings', () => { + cy.visit('/settings/user/theming') + + cy.get('.settings-section').contains('Navigation bar settings').should('exist') + cy.get('[data-cy-app-order]').scrollIntoView() + }) + + 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]') + .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('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') + + 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() + 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(() => { + cy.resetAdminTheming() + // install a third app + installTestApp() + // set files as default app + cy.runOccCommand('config:system:set --value \'files\' defaultapp') + + // Create random user for this test + cy.createRandomUser().then(($user) => { + user = $user + cy.login($user) + }) + }) + + after(() => { + cy.deleteUser(user) + uninstallTestApp() + }) + + it('See files is the default app', () => { + // 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('See the app order settings: files is the first one', () => { + cy.visit('/settings/user/theming') + cy.get('[data-cy-app-order]').scrollIntoView() + + 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', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible') + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="down"]').should('not.be.visible') + + cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="up"]').should('not.be.visible') + cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="down"]').should('be.visible') + + cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="up"]').should('be.visible') + cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="down"]').should('not.be.visible') + }) + + it('Change the order of the other apps', () => { + 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('@updateAppOrder') + cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="up"]').click() + 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 + 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() + + 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])) + }) +}) + +describe('User theming app order list accessibility', () => { + let user: User + + before(() => { + cy.resetAdminTheming() + // Create random user for this test + cy.createRandomUser().then(($user) => { + user = $user + cy.login($user) + }) + }) + + after(() => { + cy.deleteUser(user) + }) + + it('See the app order settings', () => { + cy.visit('/settings/user/theming') + cy.get('[data-cy-app-order]').scrollIntoView() + cy.get('[data-cy-app-order] [data-cy-app-order-element]').should('have.length', 2) + }) + + 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', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="down"]').should('not.have.focus') + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.have.focus') + cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="down"]').should('not.have.focus') + cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="up"]').should('have.focus') + }) + + 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', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="down"]').should('not.have.focus') + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.have.focus') + cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="up"]').should('not.have.focus') + cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="down"]').should('have.focus') + }) +}) + +describe('User theming reset app order', () => { + const navigationHeader = new NavigationHeader() + let user: User + + before(() => { + cy.resetAdminTheming() + // Create random user for this test + cy.createRandomUser().then(($user) => { + user = $user + cy.login($user) + }) + }) + + after(() => cy.deleteUser(user)) + + it('See the app order settings', () => { + cy.visit('/settings/user/theming') + + cy.get('.settings-section').contains('Navigation bar settings').should('exist') + cy.get('[data-cy-app-order]').scrollIntoView() + }) + + 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]') + .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', () => { + cy.get('[data-test-id="btn-apporder-reset"]').scrollIntoView() + cy.get('[data-test-id="btn-apporder-reset"]').should('be.visible').and('have.attr', 'disabled') + }) + + 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 + 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', () => { + cy.get('[data-test-id="btn-apporder-reset"]').scrollIntoView() + cy.get('[data-test-id="btn-apporder-reset"]').should('be.visible').and('not.have.attr', 'disabled') + }) + + 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', () => { + 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', () => { + cy.get('[data-test-id="btn-apporder-reset"]').should('be.visible').and('have.attr', 'disabled') + }) +}) diff --git a/cypress/e2e/theming/user-settings_background.cy.ts b/cypress/e2e/theming/user-settings_background.cy.ts new file mode 100644 index 00000000000..8abcb5bace1 --- /dev/null +++ b/cypress/e2e/theming/user-settings_background.cy.ts @@ -0,0 +1,302 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { User } from '@nextcloud/cypress' + +import { defaultPrimary, defaultBackground, validateBodyThemingCss } from './themingUtils' +import { NavigationHeader } from '../../pages/NavigationHeader' + +const admin = new User('admin', 'admin') + +describe('User default background settings', function() { + before(function() { + cy.resetAdminTheming() + cy.resetUserTheming(admin) + cy.createRandomUser().then((user: User) => { + cy.login(user) + }) + }) + + it('See the user background settings', function() { + cy.visit('/settings/user/theming') + cy.get('[data-user-theming-background-settings]').scrollIntoView() + cy.get('[data-user-theming-background-settings]').should('be.visible') + }) + + // Default cloud background is not rendered if admin theming background remains unchanged + it('Default cloud background is not rendered', function() { + cy.get(`[data-user-theming-background-shipped="${defaultBackground}"]`).should('not.exist') + }) + + it('Default is selected on new users', function() { + cy.get('[data-user-theming-background-default]').should('be.visible') + cy.get('[data-user-theming-background-default]').should('have.class', 'background--active') + }) + + it('Default background has accessibility attribute set', function() { + cy.get('[data-user-theming-background-default]').should('have.attr', 'aria-pressed', 'true') + }) +}) + +describe('User select shipped backgrounds and remove background', function() { + before(function() { + cy.createRandomUser().then((user: User) => { + cy.login(user) + }) + }) + + it('See the user background settings', function() { + cy.visit('/settings/user/theming') + cy.get('[data-user-theming-background-settings]').scrollIntoView() + cy.get('[data-user-theming-background-settings]').should('be.visible') + }) + + it('Select a shipped background', function() { + const background = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg' + cy.intercept('*/apps/theming/background/shipped').as('setBackground') + + // Select background + cy.get(`[data-user-theming-background-shipped="${background}"]`).click() + + // Set the accessibility state + cy.get(`[data-user-theming-background-shipped="${background}"]`).should('have.attr', 'aria-pressed', 'true') + + // Validate changed background and primary + cy.wait('@setBackground') + cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11')) + }) + + it('Select a bright shipped background', function() { + const background = 'bernie-cetonia-aurata-take-off-composition.jpg' + cy.intercept('*/apps/theming/background/shipped').as('setBackground') + + // Select background + cy.get(`[data-user-theming-background-shipped="${background}"]`).click() + + // Set the accessibility state + cy.get(`[data-user-theming-background-shipped="${background}"]`).should('have.attr', 'aria-pressed', 'true') + + // Validate changed background and primary + cy.wait('@setBackground') + cy.waitUntil(() => validateBodyThemingCss('#56633d', background, '#dee0d3')) + }) + + it('Remove background', function() { + cy.intercept('*/apps/theming/background/color').as('clearBackground') + + // Clear background + cy.get('[data-user-theming-background-color]').click() + + // Set the accessibility state + cy.get('[data-user-theming-background-color]').should('have.attr', 'aria-pressed', 'true') + + // Validate clear background + cy.wait('@clearBackground') + cy.waitUntil(() => validateBodyThemingCss('#56633d', null, '#dee0d3')) + }) +}) + +describe('User select a custom color', function() { + before(function() { + cy.createRandomUser().then((user: User) => { + cy.login(user) + }) + }) + + it('See the user background settings', function() { + cy.visit('/settings/user/theming') + cy.get('[data-user-theming-background-settings]').scrollIntoView() + cy.get('[data-user-theming-background-settings]').should('be.visible') + }) + + it('Select a custom color', function() { + cy.intercept('*/apps/theming/background/color').as('setColor') + + 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(() => 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) + }) + }) + + it('See the user background settings', function() { + cy.visit('/settings/user/theming') + cy.get('[data-user-theming-background-settings]').scrollIntoView() + cy.get('[data-user-theming-background-settings]').should('be.visible') + }) + + it('Remove background', function() { + cy.intercept('*/apps/theming/background/color').as('clearBackground') + + // Clear background + cy.get('[data-user-theming-background-color]').click() + cy.get('[data-user-theming-background-color]').click() + + // Validate clear background + cy.wait('@clearBackground') + cy.waitUntil(() => validateBodyThemingCss(undefined, null)) + }) + + it('Select a custom color', function() { + cy.intercept('*/apps/theming/background/color').as('setColor') + + // Pick one of the bright color preset + 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 + cy.wait('@setColor') + }) + + it('See the header being inverted', function() { + cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => { + let ret = true + el.each(function() { + ret = ret && window.getComputedStyle(this).filter === 'invert(1)' + }) + return ret + })) + }) + + it('Select another but non-bright shipped background', function() { + const background = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg' + cy.intercept('*/apps/theming/background/shipped').as('setBackground') + + // Select background + cy.get(`[data-user-theming-background-shipped="${background}"]`).click() + + // Validate changed background and primary + cy.wait('@setBackground') + cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11')) + }) + + it('See the header NOT being inverted this time', function() { + cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => { + let ret = true + el.each(function() { + ret = ret && window.getComputedStyle(this).filter === 'none' + }) + return ret + })) + }) +}) + +describe('User select a custom background', function() { + const image = 'image.jpg' + before(function() { + cy.createRandomUser().then((user: User) => { + cy.uploadFile(user, image, 'image/jpeg') + cy.login(user) + }) + }) + + it('See the user background settings', function() { + cy.visit('/settings/user/theming') + cy.get('[data-user-theming-background-settings]').scrollIntoView() + cy.get('[data-user-theming-background-settings]').should('be.visible') + }) + + it('Select a custom background', function() { + cy.intercept('*/apps/theming/background/custom').as('setBackground') + + cy.on('uncaught:exception', (err) => { + // This can happen because of blink engine & skeleton animation, its not a bug just engine related. + if (err.message.includes('ResizeObserver loop limit exceeded')) { + return false + } + }) + + // Pick background + cy.get('[data-user-theming-background-custom]').click() + cy.get('.file-picker__files tr').contains(image).click() + cy.get('.dialog__actions .button-vue--vue-primary').click() + + // Wait for background to be set + cy.wait('@setBackground') + cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', '#2f2221')) + }) +}) + +describe('User changes settings and reload the page', function() { + const image = 'image.jpg' + const colorFromImage = '#2f2221' + + before(function() { + cy.createRandomUser().then((user: User) => { + cy.uploadFile(user, image, 'image/jpeg') + cy.login(user) + }) + }) + + it('See the user background settings', function() { + cy.visit('/settings/user/theming') + cy.get('[data-user-theming-background-settings]').scrollIntoView() + cy.get('[data-user-theming-background-settings]').should('be.visible') + }) + + it('Select a custom background', function() { + cy.intercept('*/apps/theming/background/custom').as('setBackground') + + cy.on('uncaught:exception', (err) => { + // This can happen because of blink engine & skeleton animation, its not a bug just engine related. + if (err.message.includes('ResizeObserver loop limit exceeded')) { + return false + } + }) + + // Pick background + cy.get('[data-user-theming-background-custom]').click() + cy.get('.file-picker__files tr').contains(image).click() + cy.get('.dialog__actions .button-vue--vue-primary').click() + + // Wait for background to be set + cy.wait('@setBackground') + cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', colorFromImage)) + }) + + it('Select a custom color', function() { + cy.intercept('*/apps/theming/background/color').as('setColor') + + 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(() => 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('#c98879', null, '#a5b872')) + }) +}) |