0
0
Fork 0
mirror of https://github.com/nextcloud/server.git synced 2025-04-13 21:09:42 +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: {
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',
},
}
},
],
}

View file

@ -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 !== '') {

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) {
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>`,
})
}
/**

View file

@ -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')

View file

@ -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)
})
})
})

View file

@ -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)
})
})
})

View file

@ -69,10 +69,17 @@ describe('Versions naming', () => {
})
context('without edit permission', () => {
it('Does not show action', () => {
setupTestSharedFileFromUser(user, randomFileName, { update: false })
openVersionsPanel(randomFileName)
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')
@ -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)
getRowForFile(randomFileName)
.should('be.visible')
.invoke('attr', 'data-cy-files-list-row-fileid')
.then(($fileId) => { fileId = $fileId })
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.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)
})
})
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)
})
})
})
})

View file

@ -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)
})
})
})

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