aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2025-03-17 11:44:53 +0100
committerFerdinand Thiessen <opensource@fthiessen.de>2025-03-19 11:57:14 +0100
commit45cfaa1b3ba9670a7c8b92fb65074cf574bfd028 (patch)
treee5948b7c896424249024a3a11153473b369bb456
parenta243e9cfbbf99007f2b592196214b7252c1635f1 (diff)
downloadnextcloud-server-45cfaa1b3ba9670a7c8b92fb65074cf574bfd028.tar.gz
nextcloud-server-45cfaa1b3ba9670a7c8b92fb65074cf574bfd028.zip
test: make cypress run in secure context and add WebAuthn tests
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
-rw-r--r--.eslintrc.js5
-rw-r--r--cypress/dockerNode.ts14
-rw-r--r--cypress/e2e/files/LivePhotosUtils.ts41
-rw-r--r--cypress/e2e/files_external/files-user-credentials.cy.ts22
-rw-r--r--cypress/e2e/files_versions/version_deletion.cy.ts25
-rw-r--r--cypress/e2e/files_versions/version_download.cy.ts27
-rw-r--r--cypress/e2e/files_versions/version_naming.cy.ts81
-rw-r--r--cypress/e2e/files_versions/version_restoration.cy.ts29
-rw-r--r--cypress/e2e/login/webauth.cy.ts152
9 files changed, 289 insertions, 107 deletions
diff --git a/.eslintrc.js b/.eslintrc.js
index 40fa92d1e8c..23dc753f8b9 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -35,6 +35,9 @@ module.exports = {
jsdoc: {
mode: 'typescript',
},
+ 'import/resolver': {
+ typescript: {}, // this loads <rootdir>/tsconfig.json to eslint
+ },
},
overrides: [
// Allow any in tests
@@ -43,6 +46,6 @@ module.exports = {
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
},
- }
+ },
],
}
diff --git a/cypress/dockerNode.ts b/cypress/dockerNode.ts
index 5da0ae96ad7..b65f164dc15 100644
--- a/cypress/dockerNode.ts
+++ b/cypress/dockerNode.ts
@@ -88,6 +88,12 @@ export const startNextcloud = async function(branch: string = getCurrentGitBranc
Type: 'tmpfs',
ReadOnly: false,
}],
+ PortBindings: {
+ '80/tcp': [{
+ HostIP: '0.0.0.0',
+ HostPort: '8083',
+ }],
+ },
},
Env: [
`BRANCH=${branch}`,
@@ -242,11 +248,15 @@ export const getContainerIP = async function(
while (ip === '' && tries < 10) {
tries++
- await container.inspect(function(err, data) {
+ container.inspect(function(err, data) {
if (err) {
throw err
}
- ip = data?.NetworkSettings?.IPAddress || ''
+ if (data?.HostConfig.PortBindings?.['80/tcp']?.[0]?.HostPort) {
+ ip = `localhost:${data.HostConfig.PortBindings['80/tcp'][0].HostPort}`
+ } else {
+ ip = data?.NetworkSettings?.IPAddress || ''
+ }
})
if (ip !== '') {
diff --git a/cypress/e2e/files/LivePhotosUtils.ts b/cypress/e2e/files/LivePhotosUtils.ts
index 9b4f1dbbf3f..34e6a1d934e 100644
--- a/cypress/e2e/files/LivePhotosUtils.ts
+++ b/cypress/e2e/files/LivePhotosUtils.ts
@@ -14,34 +14,25 @@ type SetupInfo = {
}
/**
- *
- * @param user
- * @param fileName
- * @param domain
- * @param requesttoken
- * @param metadata
*/
function setMetadata(user: User, fileName: string, requesttoken: string, metadata: object) {
- cy.url().then(url => {
- const hostname = new URL(url).hostname
- cy.request({
- method: 'PROPPATCH',
- url: `http://${hostname}/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>`,
- })
+ 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>`,
})
-
}
/**
diff --git a/cypress/e2e/files_external/files-user-credentials.cy.ts b/cypress/e2e/files_external/files-user-credentials.cy.ts
index 1911c5477c3..a0cd805312c 100644
--- a/cypress/e2e/files_external/files-user-credentials.cy.ts
+++ b/cypress/e2e/files_external/files-user-credentials.cy.ts
@@ -15,9 +15,6 @@ describe('Files user credentials', { testIsolation: true }, () => {
let user2: User
let storageUser: User
- beforeEach(() => {
- })
-
before(() => {
cy.runOccCommand('app:enable files_external')
@@ -43,8 +40,10 @@ describe('Files user credentials', { testIsolation: true }, () => {
})
it('Create a user storage with user credentials', () => {
- const url = Cypress.config('baseUrl') + '/remote.php/dav/files/' + storageUser.userId
- createStorageWithConfig(storageUser.userId, StorageBackend.DAV, AuthBackend.UserProvided, { host: url.replace('index.php/', ''), secure: 'false' })
+ // 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')
@@ -72,6 +71,7 @@ describe('Files user credentials', { testIsolation: true }, () => {
// Auth dialog should be closed and the set credentials button should be gone
cy.get('@authDialog').should('not.exist', { timeout: 2000 })
+
getActionEntryForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist')
// Finally, the storage should be accessible
@@ -81,8 +81,10 @@ describe('Files user credentials', { testIsolation: true }, () => {
})
it('Create a user storage with GLOBAL user credentials', () => {
- const url = Cypress.config('baseUrl') + '/remote.php/dav/files/' + storageUser.userId
- createStorageWithConfig('storage1', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host: url.replace('index.php/', ''), secure: 'false' })
+ // 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')
@@ -119,8 +121,10 @@ describe('Files user credentials', { testIsolation: true }, () => {
})
it('Create another user storage while reusing GLOBAL user credentials', () => {
- const url = Cypress.config('baseUrl') + '/remote.php/dav/files/' + storageUser.userId
- createStorageWithConfig('storage2', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host: url.replace('index.php/', ''), secure: 'false' })
+ // 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')
diff --git a/cypress/e2e/files_versions/version_deletion.cy.ts b/cypress/e2e/files_versions/version_deletion.cy.ts
index 944cc7a9fa8..b49aa872639 100644
--- a/cypress/e2e/files_versions/version_deletion.cy.ts
+++ b/cypress/e2e/files_versions/version_deletion.cy.ts
@@ -59,7 +59,6 @@ describe('Versions restoration', () => {
})
it('Does not work without delete permission through direct API access', () => {
- let hostname: string
let fileId: string|undefined
let versionId: string|undefined
@@ -68,24 +67,30 @@ describe('Versions restoration', () => {
navigateToFolder(folderName)
openVersionsPanel(randomFilePath)
- cy.url().then(url => { hostname = new URL(url).hostname })
- getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId })
- cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId })
+ getRowForFile(randomFileName)
+ .should('be.visible')
+ .invoke('attr', 'data-cy-files-list-row-fileid')
+ .then(($fileId) => { fileId = $fileId })
+ cy.get('[data-files-versions-version]')
+ .eq(1)
+ .invoke('attr', 'data-files-versions-version')
+ .then(($versionId) => { versionId = $versionId })
+
+ cy.logout()
cy.then(() => {
- cy.logout()
- cy.request({
+ const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
+ return cy.request({
method: 'DELETE',
+ url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
auth: { user: recipient.userId, pass: recipient.password },
headers: {
cookie: '',
},
- url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
failOnStatusCode: false,
})
- .then(({ status }) => {
- expect(status).to.equal(403)
- })
+ }).then(({ status }) => {
+ expect(status).to.equal(403)
})
})
})
diff --git a/cypress/e2e/files_versions/version_download.cy.ts b/cypress/e2e/files_versions/version_download.cy.ts
index e444749d56b..7e84a56cb5e 100644
--- a/cypress/e2e/files_versions/version_download.cy.ts
+++ b/cypress/e2e/files_versions/version_download.cy.ts
@@ -52,31 +52,36 @@ describe('Versions download', () => {
})
it('Does not work without download permission through direct API access', () => {
- let hostname: string
let fileId: string|undefined
let versionId: string|undefined
setupTestSharedFileFromUser(user, randomFileName, { download: false })
- .then(recipient => {
+ .then((recipient) => {
openVersionsPanel(randomFileName)
- cy.url().then(url => { hostname = new URL(url).hostname })
- getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId })
- cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId })
+ getRowForFile(randomFileName)
+ .should('be.visible')
+ .invoke('attr', 'data-cy-files-list-row-fileid')
+ .then(($fileId) => { fileId = $fileId })
+ cy.get('[data-files-versions-version]')
+ .eq(1)
+ .invoke('attr', 'data-files-versions-version')
+ .then(($versionId) => { versionId = $versionId })
+
+ cy.logout()
cy.then(() => {
- cy.logout()
- cy.request({
+ const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
+ return cy.request({
+ url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
auth: { user: recipient.userId, pass: recipient.password },
headers: {
cookie: '',
},
- url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
failOnStatusCode: false,
})
- .then(({ status }) => {
- expect(status).to.equal(403)
- })
+ }).then(({ status }) => {
+ expect(status).to.equal(403)
})
})
})
diff --git a/cypress/e2e/files_versions/version_naming.cy.ts b/cypress/e2e/files_versions/version_naming.cy.ts
index 980ae338490..ff299c53227 100644
--- a/cypress/e2e/files_versions/version_naming.cy.ts
+++ b/cypress/e2e/files_versions/version_naming.cy.ts
@@ -69,10 +69,17 @@ describe('Versions naming', () => {
})
context('without edit permission', () => {
- it('Does not show action', () => {
+ let recipient: User
+
+ beforeEach(() => {
setupTestSharedFileFromUser(user, randomFileName, { update: false })
- openVersionsPanel(randomFileName)
+ .then(($recipient) => {
+ recipient = $recipient
+ openVersionsPanel(randomFileName)
+ })
+ })
+ it('Does not show action', () => {
cy.get('[data-files-versions-version]').eq(0).find('.action-item__menutoggle').should('not.exist')
cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="label"]').should('not.exist')
@@ -81,45 +88,45 @@ describe('Versions naming', () => {
})
it('Does not work without update permission through direct API access', () => {
- let hostname: string
let fileId: string|undefined
let versionId: string|undefined
- setupTestSharedFileFromUser(user, randomFileName, { update: false })
- .then(recipient => {
- openVersionsPanel(randomFileName)
-
- cy.url().then(url => { hostname = new URL(url).hostname })
- getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId })
- cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId })
-
- cy.then(() => {
- cy.logout()
- cy.request({
- method: 'PROPPATCH',
- auth: { user: recipient.userId, pass: recipient.password },
- headers: {
- cookie: '',
- },
- body: `<?xml version="1.0"?>
- <d:propertyupdate xmlns:d="DAV:"
- xmlns:oc="http://owncloud.org/ns"
- xmlns:nc="http://nextcloud.org/ns"
- xmlns:ocs="http://open-collaboration-services.org/ns">
- <d:set>
- <d:prop>
- <nc:version-label>not authorized labeling</nc:version-label>
- </d:prop>
- </d:set>
- </d:propertyupdate>`,
- url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
- failOnStatusCode: false,
- })
- .then(({ status }) => {
- expect(status).to.equal(403)
- })
- })
+ getRowForFile(randomFileName)
+ .should('be.visible')
+ .invoke('attr', 'data-cy-files-list-row-fileid')
+ .then(($fileId) => { fileId = $fileId })
+
+ cy.get('[data-files-versions-version]')
+ .eq(1)
+ .invoke('attr', 'data-files-versions-version')
+ .then(($versionId) => { versionId = $versionId })
+
+ cy.logout()
+ cy.then(() => {
+ const base = Cypress.config('baseUrl')!.replace(/index\.php\/?/, '')
+ return cy.request({
+ method: 'PROPPATCH',
+ url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
+ auth: { user: recipient.userId, pass: recipient.password },
+ headers: {
+ cookie: '',
+ },
+ body: `<?xml version="1.0"?>
+ <d:propertyupdate xmlns:d="DAV:"
+ xmlns:oc="http://owncloud.org/ns"
+ xmlns:nc="http://nextcloud.org/ns"
+ xmlns:ocs="http://open-collaboration-services.org/ns">
+ <d:set>
+ <d:prop>
+ <nc:version-label>not authorized labeling</nc:version-label>
+ </d:prop>
+ </d:set>
+ </d:propertyupdate>`,
+ failOnStatusCode: false,
})
+ }).then(({ status }) => {
+ expect(status).to.equal(403)
+ })
})
})
})
diff --git a/cypress/e2e/files_versions/version_restoration.cy.ts b/cypress/e2e/files_versions/version_restoration.cy.ts
index 94c09bb9ffc..34360808f61 100644
--- a/cypress/e2e/files_versions/version_restoration.cy.ts
+++ b/cypress/e2e/files_versions/version_restoration.cy.ts
@@ -77,33 +77,38 @@ describe('Versions restoration', () => {
})
it('Does not work without update permission through direct API access', () => {
- let hostname: string
let fileId: string|undefined
let versionId: string|undefined
setupTestSharedFileFromUser(user, randomFileName, { update: false })
- .then(recipient => {
+ .then((recipient) => {
openVersionsPanel(randomFileName)
- cy.url().then(url => { hostname = new URL(url).hostname })
- getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId })
- cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId })
+ getRowForFile(randomFileName)
+ .should('be.visible')
+ .invoke('attr', 'data-cy-files-list-row-fileid')
+ .then(($fileId) => { fileId = $fileId })
+ cy.get('[data-files-versions-version]')
+ .eq(1)
+ .invoke('attr', 'data-files-versions-version')
+ .then(($versionId) => { versionId = $versionId })
+
+ cy.logout()
cy.then(() => {
- cy.logout()
- cy.request({
+ const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
+ return cy.request({
method: 'MOVE',
+ url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
auth: { user: recipient.userId, pass: recipient.password },
headers: {
cookie: '',
- Destination: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/restore/target`,
+ Destination: `${base}}/remote.php/dav/versions/${recipient.userId}/restore/target`,
},
- url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
failOnStatusCode: false,
})
- .then(({ status }) => {
- expect(status).to.equal(403)
- })
+ }).then(({ status }) => {
+ expect(status).to.equal(403)
})
})
})
diff --git a/cypress/e2e/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(\/|$)/)
+ })
+})