0
0
Fork 0
mirror of https://github.com/nextcloud/server.git synced 2025-04-25 09:39:52 +00:00

test: make cypress run in secure context and add WebAuthn tests

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2025-03-17 11:44:53 +01:00
parent a243e9cfbb
commit 45cfaa1b3b
No known key found for this signature in database
GPG key ID: 45FAE7268762B400
9 changed files with 288 additions and 106 deletions

View file

@ -35,6 +35,9 @@ module.exports = {
jsdoc: { jsdoc: {
mode: 'typescript', mode: 'typescript',
}, },
'import/resolver': {
typescript: {}, // this loads <rootdir>/tsconfig.json to eslint
},
}, },
overrides: [ overrides: [
// Allow any in tests // Allow any in tests
@ -43,6 +46,6 @@ module.exports = {
rules: { rules: {
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
}, },
} },
], ],
} }

View file

@ -88,6 +88,12 @@ export const startNextcloud = async function(branch: string = getCurrentGitBranc
Type: 'tmpfs', Type: 'tmpfs',
ReadOnly: false, ReadOnly: false,
}], }],
PortBindings: {
'80/tcp': [{
HostIP: '0.0.0.0',
HostPort: '8083',
}],
},
}, },
Env: [ Env: [
`BRANCH=${branch}`, `BRANCH=${branch}`,
@ -242,11 +248,15 @@ export const getContainerIP = async function(
while (ip === '' && tries < 10) { while (ip === '' && tries < 10) {
tries++ tries++
await container.inspect(function(err, data) { container.inspect(function(err, data) {
if (err) { if (err) {
throw 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 !== '') { if (ip !== '') {

View file

@ -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) { function setMetadata(user: User, fileName: string, requesttoken: string, metadata: object) {
cy.url().then(url => { const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?/, '')
const hostname = new URL(url).hostname cy.request({
cy.request({ method: 'PROPPATCH',
method: 'PROPPATCH', url: `${base}/remote.php/dav/files/${user.userId}/${fileName}`,
url: `http://${hostname}/remote.php/dav/files/${user.userId}/${fileName}`, auth: { user: user.userId, pass: user.password },
auth: { user: user.userId, pass: user.password }, headers: {
headers: { requesttoken,
requesttoken, },
}, body: `<?xml version="1.0"?>
body: `<?xml version="1.0"?> <d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
<d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns"> <d:set>
<d:set> <d:prop>
<d:prop> ${Object.entries(metadata).map(([key, value]) => `<${key}>${value}</${key}>`).join('\n')}
${Object.entries(metadata).map(([key, value]) => `<${key}>${value}</${key}>`).join('\n')} </d:prop>
</d:prop> </d:set>
</d:set> </d:propertyupdate>`,
</d:propertyupdate>`,
})
}) })
} }
/** /**

View file

@ -15,9 +15,6 @@ describe('Files user credentials', { testIsolation: true }, () => {
let user2: User let user2: User
let storageUser: User let storageUser: User
beforeEach(() => {
})
before(() => { before(() => {
cy.runOccCommand('app:enable files_external') cy.runOccCommand('app:enable files_external')
@ -43,8 +40,10 @@ describe('Files user credentials', { testIsolation: true }, () => {
}) })
it('Create a user storage with user credentials', () => { it('Create a user storage with user credentials', () => {
const url = Cypress.config('baseUrl') + '/remote.php/dav/files/' + storageUser.userId // Its not the public server address but the address so the server itself can connect to it
createStorageWithConfig(storageUser.userId, StorageBackend.DAV, AuthBackend.UserProvided, { host: url.replace('index.php/', ''), secure: 'false' }) 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.login(user1)
cy.visit('/apps/files/extstoragemounts') 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 // Auth dialog should be closed and the set credentials button should be gone
cy.get('@authDialog').should('not.exist', { timeout: 2000 }) cy.get('@authDialog').should('not.exist', { timeout: 2000 })
getActionEntryForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') getActionEntryForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist')
// Finally, the storage should be accessible // 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', () => { it('Create a user storage with GLOBAL user credentials', () => {
const url = Cypress.config('baseUrl') + '/remote.php/dav/files/' + storageUser.userId // Its not the public server address but the address so the server itself can connect to it
createStorageWithConfig('storage1', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host: url.replace('index.php/', ''), secure: 'false' }) 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.login(user2)
cy.visit('/apps/files/extstoragemounts') 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', () => { it('Create another user storage while reusing GLOBAL user credentials', () => {
const url = Cypress.config('baseUrl') + '/remote.php/dav/files/' + storageUser.userId // Its not the public server address but the address so the server itself can connect to it
createStorageWithConfig('storage2', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host: url.replace('index.php/', ''), secure: 'false' }) 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.login(user2)
cy.visit('/apps/files/extstoragemounts') cy.visit('/apps/files/extstoragemounts')

View file

@ -59,7 +59,6 @@ describe('Versions restoration', () => {
}) })
it('Does not work without delete permission through direct API access', () => { it('Does not work without delete permission through direct API access', () => {
let hostname: string
let fileId: string|undefined let fileId: string|undefined
let versionId: string|undefined let versionId: string|undefined
@ -68,24 +67,30 @@ describe('Versions restoration', () => {
navigateToFolder(folderName) navigateToFolder(folderName)
openVersionsPanel(randomFilePath) openVersionsPanel(randomFilePath)
cy.url().then(url => { hostname = new URL(url).hostname }) getRowForFile(randomFileName)
getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId }) .should('be.visible')
cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId }) .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.then(() => {
cy.logout() const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
cy.request({ return cy.request({
method: 'DELETE', method: 'DELETE',
url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
auth: { user: recipient.userId, pass: recipient.password }, auth: { user: recipient.userId, pass: recipient.password },
headers: { headers: {
cookie: '', cookie: '',
}, },
url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
failOnStatusCode: false, failOnStatusCode: false,
}) })
.then(({ status }) => { }).then(({ status }) => {
expect(status).to.equal(403) expect(status).to.equal(403)
})
}) })
}) })
}) })

View file

@ -52,31 +52,36 @@ describe('Versions download', () => {
}) })
it('Does not work without download permission through direct API access', () => { it('Does not work without download permission through direct API access', () => {
let hostname: string
let fileId: string|undefined let fileId: string|undefined
let versionId: string|undefined let versionId: string|undefined
setupTestSharedFileFromUser(user, randomFileName, { download: false }) setupTestSharedFileFromUser(user, randomFileName, { download: false })
.then(recipient => { .then((recipient) => {
openVersionsPanel(randomFileName) openVersionsPanel(randomFileName)
cy.url().then(url => { hostname = new URL(url).hostname }) getRowForFile(randomFileName)
getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId }) .should('be.visible')
cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId }) .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.then(() => {
cy.logout() const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
cy.request({ return cy.request({
url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
auth: { user: recipient.userId, pass: recipient.password }, auth: { user: recipient.userId, pass: recipient.password },
headers: { headers: {
cookie: '', cookie: '',
}, },
url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
failOnStatusCode: false, failOnStatusCode: false,
}) })
.then(({ status }) => { }).then(({ status }) => {
expect(status).to.equal(403) expect(status).to.equal(403)
})
}) })
}) })
}) })

View file

@ -69,10 +69,17 @@ describe('Versions naming', () => {
}) })
context('without edit permission', () => { context('without edit permission', () => {
it('Does not show action', () => { let recipient: User
setupTestSharedFileFromUser(user, randomFileName, { update: false })
openVersionsPanel(randomFileName)
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).find('.action-item__menutoggle').should('not.exist')
cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="label"]').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', () => { it('Does not work without update permission through direct API access', () => {
let hostname: string
let fileId: string|undefined let fileId: string|undefined
let versionId: string|undefined let versionId: string|undefined
setupTestSharedFileFromUser(user, randomFileName, { update: false }) getRowForFile(randomFileName)
.then(recipient => { .should('be.visible')
openVersionsPanel(randomFileName) .invoke('attr', 'data-cy-files-list-row-fileid')
.then(($fileId) => { fileId = $fileId })
cy.url().then(url => { hostname = new URL(url).hostname }) cy.get('[data-files-versions-version]')
getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId }) .eq(1)
cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId }) .invoke('attr', 'data-files-versions-version')
.then(($versionId) => { versionId = $versionId })
cy.then(() => { cy.logout()
cy.logout() cy.then(() => {
cy.request({ const base = Cypress.config('baseUrl')!.replace(/index\.php\/?/, '')
method: 'PROPPATCH', return cy.request({
auth: { user: recipient.userId, pass: recipient.password }, method: 'PROPPATCH',
headers: { url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
cookie: '', auth: { user: recipient.userId, pass: recipient.password },
}, headers: {
body: `<?xml version="1.0"?> cookie: '',
<d:propertyupdate xmlns:d="DAV:" },
xmlns:oc="http://owncloud.org/ns" body: `<?xml version="1.0"?>
xmlns:nc="http://nextcloud.org/ns" <d:propertyupdate xmlns:d="DAV:"
xmlns:ocs="http://open-collaboration-services.org/ns"> xmlns:oc="http://owncloud.org/ns"
<d:set> xmlns:nc="http://nextcloud.org/ns"
<d:prop> xmlns:ocs="http://open-collaboration-services.org/ns">
<nc:version-label>not authorized labeling</nc:version-label> <d:set>
</d:prop> <d:prop>
</d:set> <nc:version-label>not authorized labeling</nc:version-label>
</d:propertyupdate>`, </d:prop>
url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`, </d:set>
failOnStatusCode: false, </d:propertyupdate>`,
}) failOnStatusCode: false,
.then(({ status }) => {
expect(status).to.equal(403)
})
})
}) })
}).then(({ status }) => {
expect(status).to.equal(403)
})
}) })
}) })
}) })

View file

@ -77,33 +77,38 @@ describe('Versions restoration', () => {
}) })
it('Does not work without update permission through direct API access', () => { it('Does not work without update permission through direct API access', () => {
let hostname: string
let fileId: string|undefined let fileId: string|undefined
let versionId: string|undefined let versionId: string|undefined
setupTestSharedFileFromUser(user, randomFileName, { update: false }) setupTestSharedFileFromUser(user, randomFileName, { update: false })
.then(recipient => { .then((recipient) => {
openVersionsPanel(randomFileName) openVersionsPanel(randomFileName)
cy.url().then(url => { hostname = new URL(url).hostname }) getRowForFile(randomFileName)
getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId }) .should('be.visible')
cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId }) .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.then(() => {
cy.logout() const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
cy.request({ return cy.request({
method: 'MOVE', method: 'MOVE',
url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
auth: { user: recipient.userId, pass: recipient.password }, auth: { user: recipient.userId, pass: recipient.password },
headers: { headers: {
cookie: '', 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, failOnStatusCode: false,
}) })
.then(({ status }) => { }).then(({ status }) => {
expect(status).to.equal(403) expect(status).to.equal(403)
})
}) })
}) })
}) })

View file

@ -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(\/|$)/)
})
})