From 4a90d5328cae20e913612eec447346b55c2c12ae Mon Sep 17 00:00:00 2001
From: Ferdinand Thiessen <opensource@fthiessen.de>
Date: Thu, 5 Sep 2024 11:28:17 +0200
Subject: [PATCH] test: Add end-to-end tests for new public share Vue UI

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
---
 __tests__/mock-window.js                      |   6 +-
 .../src/actions/editLocallyAction.spec.ts     |   3 +-
 apps/files/src/actions/editLocallyAction.ts   |   2 +-
 cypress.config.ts                             |  13 ++
 cypress/dockerNode.ts                         |   6 +-
 cypress/e2e/files/FilesUtils.ts               |  10 +-
 .../e2e/files_sharing/FilesSharingUtils.ts    |  11 --
 cypress/e2e/files_sharing/file-request.cy.ts  |  44 +++--
 .../public-share/copy-move-files.cy.ts        |  49 +++++
 .../public-share/download-files.cy.ts         | 141 +++++++++++++++
 .../header-menu.cy.ts}                        | 134 ++++++--------
 .../public-share/rename-files.cy.ts           |  32 ++++
 .../public-share/setup-public-share.ts        | 119 ++++++++++++
 .../public-share/view_file-drop.cy.ts         | 169 ++++++++++++++++++
 .../view_view-only-no-download.cy.ts          | 104 +++++++++++
 .../public-share/view_view-only.cy.ts         | 107 +++++++++++
 cypress/support/commands.ts                   |  36 +++-
 cypress/support/utils/assertions.ts           |   4 +-
 package-lock.json                             |   2 +-
 package.json                                  |   2 +-
 vitest.config.ts                              |   5 +
 21 files changed, 879 insertions(+), 120 deletions(-)
 create mode 100644 cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts
 create mode 100644 cypress/e2e/files_sharing/public-share/download-files.cy.ts
 rename cypress/e2e/files_sharing/{public-share-header-menu.cy.ts => public-share/header-menu.cy.ts} (61%)
 create mode 100644 cypress/e2e/files_sharing/public-share/rename-files.cy.ts
 create mode 100644 cypress/e2e/files_sharing/public-share/setup-public-share.ts
 create mode 100644 cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts
 create mode 100644 cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts
 create mode 100644 cypress/e2e/files_sharing/public-share/view_view-only.cy.ts

diff --git a/__tests__/mock-window.js b/__tests__/mock-window.js
index dbb689a4f2c21..e0c8607dc221e 100644
--- a/__tests__/mock-window.js
+++ b/__tests__/mock-window.js
@@ -2,12 +2,8 @@
  * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
  * SPDX-License-Identifier: AGPL-3.0-or-later
  */
-import { beforeEach } from 'vitest'
-
 window.OC = { ...window.OC }
 window.OCA = { ...window.OCA }
 window.OCP = { ...window.OCP }
 
-beforeEach(() => {
-	window.location = new URL('http://nextcloud.local')
-})
+window._oc_webroot = ''
diff --git a/apps/files/src/actions/editLocallyAction.spec.ts b/apps/files/src/actions/editLocallyAction.spec.ts
index 4d07bb151896d..9c7de1b78be31 100644
--- a/apps/files/src/actions/editLocallyAction.spec.ts
+++ b/apps/files/src/actions/editLocallyAction.spec.ts
@@ -120,6 +120,7 @@ describe('Edit locally action execute tests', () => {
 			data: { ocs: { data: { token: 'foobar' } } },
 		}))
 		const showError = vi.spyOn(nextcloudDialogs, 'showError')
+		const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
 
 		const file = new File({
 			id: 1,
@@ -138,7 +139,7 @@ describe('Edit locally action execute tests', () => {
 		expect(axios.post).toBeCalledTimes(1)
 		expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files/api/v1/openlocaleditor?format=json', { path: '/foobar.txt' })
 		expect(showError).toBeCalledTimes(0)
-		expect(window.location.href).toBe('nc://open/test@nextcloud.local/foobar.txt?token=foobar')
+		expect(windowOpenSpy).toBeCalledWith('nc://open/test@nextcloud.local/foobar.txt?token=foobar', '_self')
 	})
 
 	test('Edit locally fails and shows error', async () => {
diff --git a/apps/files/src/actions/editLocallyAction.ts b/apps/files/src/actions/editLocallyAction.ts
index 2471eaf40a55c..ae35b0ca4098a 100644
--- a/apps/files/src/actions/editLocallyAction.ts
+++ b/apps/files/src/actions/editLocallyAction.ts
@@ -73,7 +73,7 @@ const openLocalClient = async function(path: string) {
 		let url = `nc://open/${uid}@` + window.location.host + encodePath(path)
 		url += '?token=' + result.data.ocs.data.token
 
-		window.location.href = url
+		window.open(url, '_self')
 	} catch (error) {
 		showError(t('files', 'Failed to redirect to client'))
 	}
diff --git a/cypress.config.ts b/cypress.config.ts
index e9b09f2012d11..9f5c394b4c687 100644
--- a/cypress.config.ts
+++ b/cypress.config.ts
@@ -66,6 +66,19 @@ export default defineConfig({
 
 			on('task', { removeDirectory })
 
+			// This allows to store global data (e.g. the name of a snapshot)
+			// because Cypress.env() and other options are local to the current spec file.
+			const data = {}
+			on('task', {
+				setVariable({ key, value }) {
+					data[key] = value
+					return null
+				},
+				getVariable({ key }) {
+					return data[key] ?? null
+				},
+			})
+
 			// Disable spell checking to prevent rendering differences
 			on('before:browser:launch', (browser, launchOptions) => {
 				if (browser.family === 'chromium' && browser.name !== 'electron') {
diff --git a/cypress/dockerNode.ts b/cypress/dockerNode.ts
index a9e8c7c6c4574..71644ae73994e 100644
--- a/cypress/dockerNode.ts
+++ b/cypress/dockerNode.ts
@@ -147,6 +147,8 @@ export const configureNextcloud = async function() {
 	// Saving DB state
 	console.log('├─ Creating init DB snapshot...')
 	await runExec(container, ['cp', '/var/www/html/data/owncloud.db', '/var/www/html/data/owncloud.db-init'], true)
+	console.log('├─ Creating init data backup...')
+	await runExec(container, ['tar', 'cf', 'data-init.tar', 'admin'], true, undefined, '/var/www/html/data')
 
 	console.log('└─ Nextcloud is now ready to use 🎉')
 }
@@ -277,9 +279,11 @@ const runExec = async function(
 	command: string[],
 	verbose = false,
 	user = 'www-data',
+	workdir?: string,
 ): Promise<string> {
 	const exec = await container.exec({
 		Cmd: command,
+		WorkingDir: workdir,
 		AttachStdout: true,
 		AttachStderr: true,
 		User: user,
@@ -296,7 +300,7 @@ const runExec = async function(
 				stream.on('data', str => {
 					str = str.trim()
 						// Remove non printable characters
-						.replace(/[^\x20-\x7E]+/g, '')
+						.replace(/[^\x0A\x0D\x20-\x7E]+/g, '')
 						// Remove non alphanumeric leading characters
 						.replace(/^[^a-z]/gi, '')
 					output += str
diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts
index 345b6402f1f04..0f2b11542002c 100644
--- a/cypress/e2e/files/FilesUtils.ts
+++ b/cypress/e2e/files/FilesUtils.ts
@@ -9,8 +9,8 @@ export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-r
 export const getActionsForFileId = (fileid: number) => getRowForFileId(fileid).find('[data-cy-files-list-row-actions]')
 export const getActionsForFile = (filename: string) => getRowForFile(filename).find('[data-cy-files-list-row-actions]')
 
-export const getActionButtonForFileId = (fileid: number) => getActionsForFileId(fileid).find('button[aria-label="Actions"]')
-export const getActionButtonForFile = (filename: string) => getActionsForFile(filename).find('button[aria-label="Actions"]')
+export const getActionButtonForFileId = (fileid: number) => getActionsForFileId(fileid).findByRole('button', { name: 'Actions' })
+export const getActionButtonForFile = (filename: string) => getActionsForFile(filename).findByRole('button', { name: 'Actions' })
 
 export const triggerActionForFileId = (fileid: number, actionId: string) => {
 	getActionButtonForFileId(fileid).click()
@@ -34,7 +34,7 @@ export const moveFile = (fileName: string, dirPath: string) => {
 
 	cy.get('.file-picker').within(() => {
 		// intercept the copy so we can wait for it
-		cy.intercept('MOVE', /\/remote.php\/dav\/files\//).as('moveFile')
+		cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile')
 
 		if (dirPath === '/') {
 			// select home folder
@@ -65,7 +65,7 @@ export const copyFile = (fileName: string, dirPath: string) => {
 
 	cy.get('.file-picker').within(() => {
 		// intercept the copy so we can wait for it
-		cy.intercept('COPY', /\/remote.php\/dav\/files\//).as('copyFile')
+		cy.intercept('COPY', /\/(remote|public)\.php\/dav\/files\//).as('copyFile')
 
 		if (dirPath === '/') {
 			// select home folder
@@ -95,7 +95,7 @@ export const renameFile = (fileName: string, newFileName: string) => {
 	triggerActionForFile(fileName, 'rename')
 
 	// intercept the move so we can wait for it
-	cy.intercept('MOVE', /\/remote.php\/dav\/files\//).as('moveFile')
+	cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile')
 
 	getRowForFile(fileName).find('[data-cy-files-list-row-name] input').clear()
 	getRowForFile(fileName).find('[data-cy-files-list-row-name] input').type(`${newFileName}{enter}`)
diff --git a/cypress/e2e/files_sharing/FilesSharingUtils.ts b/cypress/e2e/files_sharing/FilesSharingUtils.ts
index ef8cf462a06c9..6e97a757a1529 100644
--- a/cypress/e2e/files_sharing/FilesSharingUtils.ts
+++ b/cypress/e2e/files_sharing/FilesSharingUtils.ts
@@ -170,14 +170,3 @@ export const createFileRequest = (path: string, options: FileRequestOptions = {}
 	// Close
 	cy.get('[data-cy-file-request-dialog-controls="finish"]').click()
 }
-
-export const enterGuestName = (name: string) => {
-	cy.get('[data-cy-public-auth-prompt-dialog]').should('be.visible')
-	cy.get('[data-cy-public-auth-prompt-dialog-name]').should('be.visible')
-	cy.get('[data-cy-public-auth-prompt-dialog-submit]').should('be.visible')
-
-	cy.get('[data-cy-public-auth-prompt-dialog-name]').type(`{selectall}${name}`)
-	cy.get('[data-cy-public-auth-prompt-dialog-submit]').click()
-
-	cy.get('[data-cy-public-auth-prompt-dialog]').should('not.exist')
-}
diff --git a/cypress/e2e/files_sharing/file-request.cy.ts b/cypress/e2e/files_sharing/file-request.cy.ts
index 7c33594e25cba..d109c4c585dfa 100644
--- a/cypress/e2e/files_sharing/file-request.cy.ts
+++ b/cypress/e2e/files_sharing/file-request.cy.ts
@@ -5,12 +5,31 @@
 
 import type { User } from '@nextcloud/cypress'
 import { createFolder, getRowForFile, navigateToFolder } from '../files/FilesUtils'
-import { createFileRequest, enterGuestName } from './FilesSharingUtils'
+import { createFileRequest } from './FilesSharingUtils'
+
+const enterGuestName = (name: string) => {
+	cy.findByRole('dialog', { name: /Upload files to/ })
+		.should('be.visible')
+		.within(() => {
+			cy.findByRole('textbox', { name: 'Nickname' })
+				.should('be.visible')
+
+			cy.findByRole('textbox', { name: 'Nickname' })
+				.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 = ''
-	let folderName = 'test-folder'
 
 	it('Login with a user and create a file request', () => {
 		cy.createRandomUser().then((_user) => {
@@ -33,19 +52,22 @@ describe('Files', { testIsolation: true }, () => {
 		enterGuestName('Guest')
 
 		// Check various elements on the page
-		cy.get('#public-upload .emptycontent').should('be.visible')
-		cy.get('#public-upload h2').contains(`Upload files to ${folderName}`)
-		cy.get('#public-upload input[type="file"]').as('fileInput').should('exist')
+		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('@fileInput').selectFile({
-			contents: Cypress.Buffer.from('abcdef'),
-			fileName: 'file.txt',
-			mimeType: 'text/plain',
-			lastModified: Date.now(),
-		}, { force: true })
+		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)
 	})
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 0000000000000..078ecf747bf4c
--- /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 './setup-public-share.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/download-files.cy.ts b/cypress/e2e/files_sharing/public-share/download-files.cy.ts
new file mode 100644
index 0000000000000..4e37d1b38ae19
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/download-files.cy.ts
@@ -0,0 +1,141 @@
+/*!
+ * 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 { zipFileContains } from '../../../support/utils/assertions.ts'
+import { getRowForFile, triggerActionForFile } from '../../files/FilesUtils.ts'
+import { getShareUrl, setupPublicShare } from './setup-public-share.ts'
+
+describe('files_sharing: Public share - downloading files', { testIsolation: true }, () => {
+
+	const shareName = 'shared'
+
+	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}/${shareName}.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>')
+		})
+	})
+})
diff --git a/cypress/e2e/files_sharing/public-share-header-menu.cy.ts b/cypress/e2e/files_sharing/public-share/header-menu.cy.ts
similarity index 61%
rename from cypress/e2e/files_sharing/public-share-header-menu.cy.ts
rename to cypress/e2e/files_sharing/public-share/header-menu.cy.ts
index 020ea410dba6d..a89ee8eb90e53 100644
--- a/cypress/e2e/files_sharing/public-share-header-menu.cy.ts
+++ b/cypress/e2e/files_sharing/public-share/header-menu.cy.ts
@@ -2,67 +2,39 @@
  * 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 { openSharingPanel } from './FilesSharingUtils.ts'
-// @ts-expect-error The package is currently broken - but works...
-import { deleteDownloadsFolderBeforeEach } from 'cypress-delete-downloads-folder'
+import { haveValidity, zipFileContains } from '../../../support/utils/assertions.ts'
+import { getShareUrl, setupPublicShare } from './setup-public-share.ts'
 
+/**
+ * This tests ensures that on public shares the header actions menu correctly works
+ */
 describe('files_sharing: Public share - header actions menu', { testIsolation: true }, () => {
 
-	let shareUrl: string
-	const shareName = 'to be 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:\/\//)
-			})
-		})
-	})
-
-	deleteDownloadsFolderBeforeEach()
-
+	before(() => setupPublicShare())
 	beforeEach(() => {
 		cy.logout()
-		cy.visit(shareUrl)
+		cy.visit(getShareUrl())
 	})
 
 	it('Can download all files', () => {
-		// Check the button
 		cy.get('header')
-			.findByRole('button', { name: 'Download all files' })
+			.findByRole('button', { name: 'Download' })
 			.should('be.visible')
 		cy.get('header')
-			.findByRole('button', { name: 'Download all files' })
+			.findByRole('button', { name: 'Download' })
 			.click()
 
 		// check a file is downloaded
 		const downloadsFolder = Cypress.config('downloadsFolder')
-		cy.readFile(`${downloadsFolder}/${shareName}.zip`, null, { timeout: 15000 })
+		cy.readFile(`${downloadsFolder}/shared.zip`, null, { timeout: 15000 })
 			.should('exist')
 			.and('have.length.gt', 30)
 			// Check all files are included
 			.and(zipFileContains([
-				`${shareName}/`,
-				`${shareName}/foo.txt`,
-				`${shareName}/subfolder/`,
-				`${shareName}/subfolder/bar.txt`,
+				'shared/',
+				'shared/foo.txt',
+				'shared/subfolder/',
+				'shared/subfolder/bar.txt',
 			]))
 	})
 
@@ -78,12 +50,12 @@ describe('files_sharing: Public share - header actions menu', { testIsolation: t
 		cy.findByRole('menu', { name: /More action/i })
 			.should('be.visible')
 		// see correct link in item
-		cy.findByRole('menuitem', { name: /Direct link/i })
+		cy.findByRole('menuitem', { name: 'Direct link' })
 			.should('be.visible')
 			.and('have.attr', 'href')
 			.then((attribute) => expect(attribute).to.match(/^http:\/\/.+\/download$/))
 		// see menu closes on click
-		cy.findByRole('menuitem', { name: /Direct link/i })
+		cy.findByRole('menuitem', { name: 'Direct link' })
 			.click()
 		cy.findByRole('menu', { name: /More actions/i })
 			.should('not.exist')
@@ -100,7 +72,7 @@ describe('files_sharing: Public share - header actions menu', { testIsolation: t
 		// See the menu
 		cy.findByRole('menu', { name: /More action/i })
 			.should('be.visible')
-		// see correct item
+		// see correct button
 		cy.findByRole('menuitem', { name: /Add to your/i })
 			.should('be.visible')
 			.click()
@@ -125,6 +97,7 @@ describe('files_sharing: Public share - header actions menu', { testIsolation: t
 			.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()
@@ -134,10 +107,11 @@ describe('files_sharing: Public share - header actions menu', { testIsolation: t
 				.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', async (req) => {
-				await promise
-				req.reply({ statusCode: 503 })
+			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()
@@ -161,7 +135,7 @@ describe('files_sharing: Public share - header actions menu', { testIsolation: t
 			.findByRole('button', { name: /More actions/i })
 			.should('be.visible')
 			.click()
-		// see correct item
+		// see correct button
 		cy.findByRole('menuitem', { name: /Add to your/i })
 			.should('be.visible')
 			.click()
@@ -183,37 +157,43 @@ describe('files_sharing: Public share - header actions menu', { testIsolation: t
 	it('See primary action is moved to menu on small screens', () => {
 		cy.viewport(490, 490)
 		// Check the button does not exist
-		cy.get('header')
-			.should('be.visible')
-			.findByRole('button', { name: 'Download all files' })
-			.should('not.exist')
-		// Open the menu
-		cy.get('header')
-			.findByRole('button', { name: /More actions/i })
-			.should('be.visible')
-			.click()
-		// See that the button is located in the menu
-		cy.findByRole('menuitem', { name: /Download all files/i })
-			.should('be.visible')
-		// See all other items are also available
+		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)
-		// Click the button to test the download
-		cy.findByRole('menuitem', { name: /Download all files/i })
-			.click()
+		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')
 
-		// check a file is downloaded
-		const downloadsFolder = Cypress.config('downloadsFolder')
-		cy.readFile(`${downloadsFolder}/${shareName}.zip`, null, { timeout: 15000 })
-			.should('exist')
-			.and('have.length.gt', 30)
-			// Check all files are included
-			.and(zipFileContains([
-				`${shareName}/`,
-				`${shareName}/foo.txt`,
-				`${shareName}/subfolder/`,
-				`${shareName}/subfolder/bar.txt`,
-			]))
+				// See that direct link works
+				cy.findByRole('menuitem', { name: 'Direct link' })
+					.should('be.visible')
+					.and('have.attr', 'href')
+					.then((attribute) => expect(attribute).to.match(/^http:\/\/.+\/download$/))
+				// 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 0000000000000..5f2fe00e6506d
--- /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 './setup-public-share.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/setup-public-share.ts b/cypress/e2e/files_sharing/public-share/setup-public-share.ts
new file mode 100644
index 0000000000000..9549552c2002a
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/setup-public-share.ts
@@ -0,0 +1,119 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { User } from '@nextcloud/cypress'
+import { openSharingPanel } from '../FilesSharingUtils.ts'
+
+let user: User
+let url: string
+
+/**
+ * URL of the share
+ */
+export function getShareUrl() {
+	if (url === undefined) {
+		throw new Error('You need to setup the share first!')
+	}
+	return url
+}
+
+/**
+ * Setup the available data
+ * @param shareName The name of the shared folder
+ */
+function setupData(shareName: string) {
+	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`)
+}
+
+/**
+ * Create a public link share
+ * @param shareName The name of the shared folder
+ */
+function createShare(shareName: string) {
+	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
+	return cy.wait('@createShare')
+		.should(({ response }) => {
+			const { ocs } = response!.body
+			url = ocs?.data.url
+			expect(url).to.match(/^http:\/\//)
+		})
+		.then(() => cy.wrap(url))
+}
+
+/**
+ * Adjust share permissions to be editable
+ */
+function adjustSharePermission() {
+	// 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()
+
+	// Enable upload-edit
+	cy.get('[data-cy-files-sharing-share-permissions-bundle]')
+		.should('be.visible')
+	cy.get('[data-cy-files-sharing-share-permissions-bundle="upload-edit"]')
+		.click()
+	// save changes
+	cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare')
+	cy.findByRole('button', { name: 'Update share' })
+		.click()
+	cy.wait('@updateShare')
+}
+
+/**
+ * Setup a public share and backup the state.
+ * If the setup was already done in another run, the state will be restored.
+ *
+ * @return The URL of the share
+ */
+export function setupPublicShare(): Cypress.Chainable<string> {
+	const shareName = 'shared'
+
+	return cy.task('getVariable', { key: 'public-share-data' })
+		.then((data) => {
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			const { dataSnapshot, dbSnapshot, shareUrl } = data as any || {}
+			if (dataSnapshot && dbSnapshot) {
+				cy.restoreDB(dbSnapshot)
+				cy.restoreData(dataSnapshot)
+				url = shareUrl
+				return cy.wrap(shareUrl as string)
+			} else {
+				cy.restoreData()
+				cy.restoreDB()
+
+				const shareData: Record<string, unknown> = {}
+				return cy.createRandomUser()
+					.then(($user) => { user = $user })
+					.then(() => setupData(shareName))
+					.then(() => createShare(shareName))
+					.then((value) => { shareData.shareUrl = value })
+					.then(() => adjustSharePermission())
+					.then(() => cy.backupDB().then((value) => { shareData.dbSnapshot = value }))
+					.then(() => cy.backupData([user.userId]).then((value) => { shareData.dataSnapshot = value }))
+					.then(() => cy.task('setVariable', { key: 'public-share-data', value: shareData }))
+					.then(() => cy.log(`Public share setup, URL: ${shareData.shareUrl}`))
+					.then(() => cy.wrap(url))
+			}
+		})
+}
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 0000000000000..8bc4b9b8e15ce
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts
@@ -0,0 +1,169 @@
+/*!
+ * 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.contains(`Upload files to ${shareName}`)
+				.should('be.visible')
+				.should('contain.text', 'agree to the terms of service')
+			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 0000000000000..abcb9ccae6236
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts
@@ -0,0 +1,104 @@
+/*!
+ * 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')
+
+		cy.contains('button', 'New')
+			.should('be.visible')
+			.and('be.disabled')
+	})
+})
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 0000000000000..4a8aa6b89a391
--- /dev/null
+++ b/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts
@@ -0,0 +1,107 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { getActionsForFile, 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')
+
+		cy.contains('button', 'New')
+			.should('be.visible')
+			.and('be.disabled')
+	})
+
+	it('Only download action is actions available', () => {
+		getActionsForFile('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/support/commands.ts b/cypress/support/commands.ts
index 1574a03705f53..23f93ea14d958 100644
--- a/cypress/support/commands.ts
+++ b/cypress/support/commands.ts
@@ -66,6 +66,8 @@ declare global {
 			 */
 			runOccCommand(command: string, options?: Partial<Cypress.ExecOptions>): Cypress.Chainable<Cypress.Exec>,
 
+			userFileExists(user: string, path: string): Cypress.Chainable<number>
+
 			/**
 			 * Create a snapshot of the current database
 			 */
@@ -75,7 +77,11 @@ declare global {
 			 * Restore a snapshot of the database
 			 * Default is the post-setup state
 			 */
-			restoreDB(snapshot?: string): Cypress.Chainable,
+			restoreDB(snapshot?: string): Cypress.Chainable
+
+			backupData(users?: string[]): Cypress.Chainable<string>
+
+			restoreData(snapshot?: string): Cypress.Chainable
 		}
 	}
 }
@@ -85,7 +91,7 @@ Cypress.env('baseUrl', url)
 
 /**
  * Enable or disable a user
- * TODO: standardise in @nextcloud/cypress
+ * TODO: standardize in @nextcloud/cypress
  *
  * @param {User} user the user to dis- / enable
  * @param {boolean} enable True if the user should be enable, false to disable
@@ -112,7 +118,7 @@ Cypress.Commands.add('enableUser', (user: User, enable = true) => {
 
 /**
  * cy.uploadedFile - uploads a file from the fixtures folder
- * TODO: standardise in @nextcloud/cypress
+ * TODO: standardize in @nextcloud/cypress
  *
  * @param {User} user the owner of the file, e.g. admin
  * @param {string} fixture the fixture file name, e.g. image1.jpg
@@ -188,7 +194,7 @@ Cypress.Commands.add('mkdir', (user: User, target: string) => {
 
 /**
  * cy.uploadedContent - uploads a raw content
- * TODO: standardise in @nextcloud/cypress
+ * TODO: standardize in @nextcloud/cypress
  *
  * @param {User} user the owner of the file, e.g. admin
  * @param {Blob} blob the content to upload
@@ -288,6 +294,13 @@ Cypress.Commands.add('runOccCommand', (command: string, options?: Partial<Cypres
 	return cy.exec(`docker exec --user www-data ${env} nextcloud-cypress-tests-server php ./occ ${command}`, options)
 })
 
+Cypress.Commands.add('userFileExists', (user: string, path: string) => {
+	user.replaceAll('"', '\\"')
+	path.replaceAll('"', '\\"').replaceAll(/^\/+/gm, '')
+	return cy.exec(`docker exec --user www-data nextcloud-cypress-tests-server stat --printf="%s" "data/${user}/files/${path}"`, { failOnNonZeroExit: true })
+		.then((exec) => Number.parseInt(exec.stdout || '0'))
+})
+
 Cypress.Commands.add('backupDB', (): Cypress.Chainable<string> => {
 	const randomString = Math.random().toString(36).substring(7)
 	cy.exec(`docker exec --user www-data nextcloud-cypress-tests-server cp /var/www/html/data/owncloud.db /var/www/html/data/owncloud.db-${randomString}`)
@@ -299,3 +312,18 @@ Cypress.Commands.add('restoreDB', (snapshot: string = 'init') => {
 	cy.exec(`docker exec --user www-data nextcloud-cypress-tests-server cp /var/www/html/data/owncloud.db-${snapshot} /var/www/html/data/owncloud.db`)
 	cy.log(`Restored snapshot ${snapshot}`)
 })
+
+Cypress.Commands.add('backupData', (users: string[] = ['admin']) => {
+	const snapshot = Math.random().toString(36).substring(7)
+	const toBackup = users.map((user) => `'${user.replaceAll('\\', '').replaceAll('\'', '\\\'')}'`).join(' ')
+	cy.exec(`docker exec --user www-data rm /var/www/html/data/data-${snapshot}.tar`, { failOnNonZeroExit: false })
+	cy.exec(`docker exec --user www-data --workdir /var/www/html/data nextcloud-cypress-tests-server tar cf /var/www/html/data/data-${snapshot}.tar ${toBackup}`)
+	return cy.wrap(snapshot as string)
+})
+
+Cypress.Commands.add('restoreData', (snapshot?: string) => {
+	snapshot = snapshot ?? 'init'
+	snapshot.replaceAll('\\', '').replaceAll('"', '\\"')
+	cy.exec(`docker exec --user www-data --workdir /var/www/html/data nextcloud-cypress-tests-server rm -vfr $(tar --exclude='*/*' -tf '/var/www/html/data/data-${snapshot}.tar')`)
+	cy.exec(`docker exec --user www-data --workdir /var/www/html/data nextcloud-cypress-tests-server tar -xf '/var/www/html/data/data-${snapshot}.tar'`)
+})
diff --git a/cypress/support/utils/assertions.ts b/cypress/support/utils/assertions.ts
index 8e5acbd306a4b..08b93b32e867c 100644
--- a/cypress/support/utils/assertions.ts
+++ b/cypress/support/utils/assertions.ts
@@ -17,9 +17,9 @@ export function zipFileContains(expectedFiles: string[]) {
 		const blob = new Blob([buffer])
 		const zip = new ZipReader(blob.stream())
 		// check the real file names
-		const entries = (await zip.getEntries()).map((e) => e.filename)
+		const entries = (await zip.getEntries()).map((e) => e.filename).sort()
 		console.info('Zip contains entries:', entries)
-		expect(entries).to.deep.equal(expectedFiles)
+		expect(entries).to.deep.equal(expectedFiles.sort())
 	}
 }
 
diff --git a/package-lock.json b/package-lock.json
index b186c8e27d68f..affc85c63619b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,7 +27,7 @@
         "@nextcloud/moment": "^1.3.1",
         "@nextcloud/password-confirmation": "^5.1.1",
         "@nextcloud/paths": "^2.2.1",
-        "@nextcloud/router": "^3.0.0",
+        "@nextcloud/router": "^3.0.1",
         "@nextcloud/sharing": "^0.2.3",
         "@nextcloud/upload": "^1.6.0",
         "@nextcloud/vue": "^8.17.1",
diff --git a/package.json b/package.json
index 40ac576524228..27f34132c8f48 100644
--- a/package.json
+++ b/package.json
@@ -58,7 +58,7 @@
     "@nextcloud/moment": "^1.3.1",
     "@nextcloud/password-confirmation": "^5.1.1",
     "@nextcloud/paths": "^2.2.1",
-    "@nextcloud/router": "^3.0.0",
+    "@nextcloud/router": "^3.0.1",
     "@nextcloud/sharing": "^0.2.3",
     "@nextcloud/upload": "^1.6.0",
     "@nextcloud/vue": "^8.17.1",
diff --git a/vitest.config.ts b/vitest.config.ts
index e58902789b351..cdf322223bdc6 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -10,6 +10,11 @@ export default defineConfig({
 	test: {
 		include: ['{apps,core}/**/*.{test,spec}.?(c|m)[jt]s?(x)'],
 		environment: 'jsdom',
+		environmentOptions: {
+			jsdom: {
+				url: 'http://nextcloud.local',
+			},
+		},
 		coverage: {
 			include: ['apps/*/src/**', 'core/src/**'],
 			exclude: ['**.spec.*', '**.test.*', '**.cy.*', 'core/src/tests/**'],