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', () => { - 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) + }) }) }) }) 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(\/|$)/) + }) +})