diff --git a/core/src/views/Setup.cy.ts b/core/src/views/Setup.cy.ts deleted file mode 100644 index 2338116a271..00000000000 --- a/core/src/views/Setup.cy.ts +++ /dev/null @@ -1,377 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { SetupConfig, SetupLinks } from '../install.ts' - -import SetupView from './Setup.vue' - -import '../../css/guest.css' - -const defaultConfig = Object.freeze({ - adminlogin: '', - adminpass: '', - dbuser: '', - dbpass: '', - dbname: '', - dbtablespace: '', - dbhost: '', - dbtype: '', - databases: { - sqlite: 'SQLite', - mysql: 'MySQL/MariaDB', - pgsql: 'PostgreSQL', - }, - directory: '', - hasAutoconfig: false, - htaccessWorking: true, - serverRoot: '/var/www/html', - errors: [], -}) as SetupConfig - -const links = { - adminInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-install', - adminSourceInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-source_install', - adminDBConfiguration: 'https://docs.nextcloud.com/server/32/go.php?to=admin-db-configuration', -} as SetupLinks - -describe('Default setup page', () => { - beforeEach(() => { - cy.mockInitialState('core', 'links', links) - }) - - afterEach(() => cy.unmockInitialState()) - - it('Renders default config', () => { - cy.mockInitialState('core', 'config', defaultConfig) - cy.mount(SetupView) - - cy.get('[data-cy-setup-form]').scrollIntoView() - cy.get('[data-cy-setup-form]').should('be.visible') - - // Single note is the footer help - cy.get('[data-cy-setup-form-note]') - .should('have.length', 1) - .should('be.visible') - cy.get('[data-cy-setup-form-note]').should('contain', 'See the documentation') - - // DB radio selectors - cy.get('[data-cy-setup-form-field^="dbtype"]') - .should('exist') - .find('input') - .should('be.checked') - - cy.get('[data-cy-setup-form-field="dbtype-mysql"]').should('exist') - cy.get('[data-cy-setup-form-field="dbtype-pgsql"]').should('exist') - cy.get('[data-cy-setup-form-field="dbtype-oci"]').should('not.exist') - - // Sqlite warning - cy.get('[data-cy-setup-form-db-note="sqlite"]') - .should('be.visible') - - // admin login, password, data directory and 3 DB radio selectors - cy.get('[data-cy-setup-form-field]') - .should('be.visible') - .should('have.length', 6) - }) - - it('Renders single DB sqlite', () => { - const config = { - ...defaultConfig, - databases: { - sqlite: 'SQLite', - }, - } - cy.mockInitialState('core', 'config', config) - cy.mount(SetupView) - - cy.get('[data-cy-setup-form-field^="dbtype"]') - .should('exist') - .should('not.be.visible') - .find('input') - .should('be.checked') - - cy.get('[data-cy-setup-form-field="dbtype-sqlite"]').should('exist') - - // Two warnings: sqlite and single db support - cy.get('[data-cy-setup-form-db-note="sqlite"]') - .should('be.visible') - cy.get('[data-cy-setup-form-db-note="single-db"]') - .should('be.visible') - - // Admin login, password, data directory and db type - cy.get('[data-cy-setup-form-field]') - .should('be.visible') - .should('have.length', 4) - }) - - it('Renders single DB mysql', () => { - const config = { - ...defaultConfig, - databases: { - mysql: 'MySQL/MariaDB', - }, - } - cy.mockInitialState('core', 'config', config) - cy.mount(SetupView) - - cy.get('[data-cy-setup-form-field^="dbtype"]') - .should('exist') - .should('not.be.visible') - .find('input') - .should('be.checked') - - // Single db support warning - cy.get('[data-cy-setup-form-db-note="single-db"]') - .should('be.visible') - .invoke('html') - .should('contains', links.adminSourceInstall) - - // No SQLite warning - cy.get('[data-cy-setup-form-db-note="sqlite"]') - .should('not.exist') - - // Admin login, password, data directory, db type, db user, - // db password, db name and db host - cy.get('[data-cy-setup-form-field]') - .should('be.visible') - .should('have.length', 8) - }) - - it('Changes fields from sqlite to mysql then oci', () => { - const config = { - ...defaultConfig, - databases: { - sqlite: 'SQLite', - mysql: 'MySQL/MariaDB', - pgsql: 'PostgreSQL', - oci: 'Oracle', - }, - } - cy.mockInitialState('core', 'config', config) - cy.mount(SetupView) - - // SQLite selected - cy.get('[data-cy-setup-form-field="dbtype-sqlite"]') - .should('be.visible') - .find('input') - .should('be.checked') - - // Admin login, password, data directory and 4 DB radio selectors - cy.get('[data-cy-setup-form-field]') - .should('be.visible') - .should('have.length', 7) - - // Change to MySQL - cy.get('[data-cy-setup-form-field="dbtype-mysql"]').click() - cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').should('be.checked') - - // Admin login, password, data directory, db user, db password, - // db name, db host and 4 DB radio selectors - cy.get('[data-cy-setup-form-field]') - .should('be.visible') - .should('have.length', 11) - - // Change to Oracle - cy.get('[data-cy-setup-form-field="dbtype-oci"]').click() - cy.get('[data-cy-setup-form-field="dbtype-oci"] input').should('be.checked') - - // Admin login, password, data directory, db user, db password, - // db name, db table space, db host and 4 DB radio selectors - cy.get('[data-cy-setup-form-field]') - .should('be.visible') - .should('have.length', 12) - cy.get('[data-cy-setup-form-field="dbtablespace"]') - .should('be.visible') - }) -}) - -describe('Setup page with errors and warning', () => { - beforeEach(() => { - cy.mockInitialState('core', 'links', links) - }) - - afterEach(() => cy.unmockInitialState()) - - it('Renders error from backend', () => { - const config = { - ...defaultConfig, - errors: [ - { - error: 'Error message', - hint: 'Error hint', - }, - ], - } - cy.mockInitialState('core', 'config', config) - cy.mount(SetupView) - - // Error message and hint - cy.get('[data-cy-setup-form-note="error"]') - .should('be.visible') - .should('have.length', 1) - .should('contain', 'Error message') - .should('contain', 'Error hint') - }) - - it('Renders errors from backend', () => { - const config = { - ...defaultConfig, - errors: [ - 'Error message 1', - { - error: 'Error message', - hint: 'Error hint', - }, - ], - } - cy.mockInitialState('core', 'config', config) - cy.mount(SetupView) - - // Error message and hint - cy.get('[data-cy-setup-form-note="error"]') - .should('be.visible') - .should('have.length', 2) - cy.get('[data-cy-setup-form-note="error"]').eq(0) - .should('contain', 'Error message 1') - cy.get('[data-cy-setup-form-note="error"]').eq(1) - .should('contain', 'Error message') - .should('contain', 'Error hint') - }) - - it('Renders all the submitted fields on error', () => { - const config = { - ...defaultConfig, - adminlogin: 'admin', - adminpass: 'password', - dbname: 'nextcloud', - dbtype: 'mysql', - dbuser: 'nextcloud', - dbpass: 'password', - dbhost: 'localhost', - directory: '/var/www/html/nextcloud', - } as SetupConfig - cy.mockInitialState('core', 'config', config) - cy.mount(SetupView) - - cy.get('input[data-cy-setup-form-field="adminlogin"]') - .should('have.value', 'admin') - cy.get('input[data-cy-setup-form-field="adminpass"]') - .should('have.value', 'password') - cy.get('[data-cy-setup-form-field="dbtype-mysql"] input') - .should('be.checked') - cy.get('input[data-cy-setup-form-field="dbname"]') - .should('have.value', 'nextcloud') - cy.get('input[data-cy-setup-form-field="dbuser"]') - .should('have.value', 'nextcloud') - cy.get('input[data-cy-setup-form-field="dbpass"]') - .should('have.value', 'password') - cy.get('input[data-cy-setup-form-field="dbhost"]') - .should('have.value', 'localhost') - cy.get('input[data-cy-setup-form-field="directory"]') - .should('have.value', '/var/www/html/nextcloud') - }) - - it('Renders the htaccess warning', () => { - const config = { - ...defaultConfig, - htaccessWorking: false, - } - cy.mockInitialState('core', 'config', config) - cy.mount(SetupView) - - cy.get('[data-cy-setup-form-note="htaccess"]') - .should('be.visible') - .should('contain', 'Security warning') - .invoke('html') - .should('contains', links.adminInstall) - }) -}) - -describe('Setup page with autoconfig', () => { - beforeEach(() => { - cy.mockInitialState('core', 'links', links) - }) - - afterEach(() => cy.unmockInitialState()) - - it('Renders autoconfig', () => { - const config = { - ...defaultConfig, - hasAutoconfig: true, - dbname: 'nextcloud', - dbtype: 'mysql', - dbuser: 'nextcloud', - dbpass: 'password', - dbhost: 'localhost', - directory: '/var/www/html/nextcloud', - } as SetupConfig - cy.mockInitialState('core', 'config', config) - cy.mount(SetupView) - - // Autoconfig info note - cy.get('[data-cy-setup-form-note="autoconfig"]') - .should('be.visible') - .should('contain', 'Autoconfig file detected') - - // Database and storage section is hidden as already set in autoconfig - cy.get('[data-cy-setup-form-advanced-config]').should('be.visible') - .invoke('attr', 'open') - .should('equal', undefined) - - // Oracle tablespace is hidden - cy.get('[data-cy-setup-form-field="dbtablespace"]') - .should('not.exist') - }) -}) - -describe('Submit a full form sends the data', () => { - beforeEach(() => { - cy.mockInitialState('core', 'links', links) - }) - - afterEach(() => cy.unmockInitialState()) - - it('Submits a full form', () => { - const config = { - ...defaultConfig, - adminlogin: 'admin', - adminpass: 'password', - dbname: 'nextcloud', - dbtype: 'mysql', - dbuser: 'nextcloud', - dbpass: 'password', - dbhost: 'localhost', - dbtablespace: 'tablespace', - directory: '/var/www/html/nextcloud', - } as SetupConfig - - cy.intercept('POST', '**', { - delay: 2000, - }).as('setup') - - cy.mockInitialState('core', 'config', config) - cy.mount(SetupView) - - // Not chaining breaks the test as the POST prevents the element from being retrieved twice - // eslint-disable-next-line cypress/unsafe-to-chain-command - cy.get('[data-cy-setup-form-submit]') - .click() - .invoke('attr', 'disabled') - .should('equal', 'disabled', { timeout: 500 }) - - cy.wait('@setup') - .its('request.body') - .should('deep.equal', new URLSearchParams({ - adminlogin: 'admin', - adminpass: 'password', - directory: '/var/www/html/nextcloud', - dbtype: 'mysql', - dbuser: 'nextcloud', - dbpass: 'password', - dbname: 'nextcloud', - dbhost: 'localhost', - }).toString()) - }) -}) diff --git a/core/src/views/Setup.spec.ts b/core/src/views/Setup.spec.ts new file mode 100644 index 00000000000..2db6607a51b --- /dev/null +++ b/core/src/views/Setup.spec.ts @@ -0,0 +1,306 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { SetupConfig, SetupLinks } from '../install.ts' + +import { cleanup, findByRole, fireEvent, getAllByRole, getByRole, render } from '@testing-library/vue' +import { beforeEach, describe, expect, it } from 'vitest' +import SetupView from './Setup.vue' + +import '../../css/guest.css' + +const defaultConfig = Object.freeze({ + adminlogin: '', + adminpass: '', + dbuser: '', + dbpass: '', + dbname: '', + dbtablespace: '', + dbhost: '', + dbtype: '', + databases: { + sqlite: 'SQLite', + mysql: 'MySQL/MariaDB', + pgsql: 'PostgreSQL', + }, + directory: '', + hasAutoconfig: false, + htaccessWorking: true, + serverRoot: '/var/www/html', + errors: [], +}) as SetupConfig + +const links = { + adminInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-install', + adminSourceInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-source_install', + adminDBConfiguration: 'https://docs.nextcloud.com/server/32/go.php?to=admin-db-configuration', +} as SetupLinks + +describe('Default setup page', () => { + beforeEach(cleanup) + beforeEach(() => { + removeInitialState() + mockInitialState('core', 'links', links) + }) + + it('Renders default config', async () => { + mockInitialState('core', 'config', defaultConfig) + const component = render(SetupView) + + // Single note is the footer help + expect(component.getAllByRole('note')).toHaveLength(1) + expect(component.getByRole('note').textContent).toContain('See the documentation') + + // DB radio selectors + const dbTypes = component.getByRole('group', { name: 'Database type' }) + expect(getAllByRole(dbTypes, 'radio')).toHaveLength(3) + await expect(findByRole(dbTypes, 'radio', { checked: true })).resolves.not.toThrow() + await expect(findByRole(dbTypes, 'radio', { name: /MySQL/ })).resolves.not.toThrow() + await expect(findByRole(dbTypes, 'radio', { name: /PostgreSQL/ })).resolves.not.toThrow() + await expect(findByRole(dbTypes, 'radio', { name: /SQLite/ })).resolves.not.toThrow() + + // Sqlite warning + await expect(component.findByText(/SQLite should only be used for minimal and development instances/)).resolves.not.toThrow() + + // admin login, password, data directory + await expect(component.findByRole('textbox', { name: 'Administration account name' })).resolves.not.toThrow() + await expect(component.findByLabelText('Administration account password')).resolves.not.toThrow() + await expect(component.findByRole('textbox', { name: 'Data folder' })).resolves.not.toThrow() + }) + + it('Renders single DB sqlite', async () => { + mockInitialState('core', 'config', { + ...defaultConfig, + databases: { + sqlite: 'SQLite', + }, + }) + const component = render(SetupView) + + const dbTypes = component.getByRole('group', { name: 'Database type' }) + expect(getAllByRole(dbTypes, 'radio', { hidden: true })).toHaveLength(1) + await expect(findByRole(dbTypes, 'radio', { name: /SQLite/, hidden: true })).resolves.not.toThrow() + + // Two warnings: sqlite and single db support + await expect(component.findByText(/Only SQLite is available./)).resolves.not.toThrow() + await expect(component.findByText(/SQLite should only be used for minimal and development instances/)).resolves.not.toThrow() + }) + + it('Renders single DB mysql', async () => { + mockInitialState('core', 'config', { + ...defaultConfig, + databases: { + mysql: 'MySQL/MariaDB', + }, + }) + const component = render(SetupView) + + const dbTypes = component.getByRole('group', { name: 'Database type' }) + expect(getAllByRole(dbTypes, 'radio', { hidden: true })).toHaveLength(1) + await expect(findByRole(dbTypes, 'radio', { name: /MySQL/, hidden: true })).resolves.not.toThrow() + + // Single db support warning + await expect(component.findByText(/Only MySQL.* is available./)).resolves.not.toThrow() + + // No SQLite warning + await expect(component.findByText(/SQLite should only be used for minimal and development instances/)).rejects.toThrow() + + // database config + await expect(component.findByRole('textbox', { name: /Database user/ })).resolves.not.toThrow() + await expect(component.findByRole('textbox', { name: /Database name/ })).resolves.not.toThrow() + await expect(component.findByRole('textbox', { name: /Database host/ })).resolves.not.toThrow() + await expect(component.findByLabelText(/Database password/)).resolves.not.toThrow() + }) + + it('Changes fields from sqlite to mysql then oci', async () => { + mockInitialState('core', 'config', { + ...defaultConfig, + databases: { + sqlite: 'SQLite', + mysql: 'MySQL/MariaDB', + pgsql: 'PostgreSQL', + oci: 'Oracle', + }, + }) + const component = render(SetupView) + + // SQLite selected + await expect(component.findByRole('radio', { name: /SQLite/, checked: true })).resolves.not.toThrow() + + // 4 db toggles + const dbTypes = component.getByRole('group', { name: 'Database type' }) + expect(getAllByRole(dbTypes, 'radio')).toHaveLength(4) + + // but no database config fields + await expect(findByRole(dbTypes, 'group', { name: /Database connection/ })).rejects.toThrow() + + // Change to MySQL + await fireEvent.click(getByRole(dbTypes, 'radio', { name: /MySQL/, checked: false })) + expect((getByRole(dbTypes, 'radio', { name: /SQLite/, checked: false }) as HTMLInputElement).checked).toBe(false) + expect((getByRole(dbTypes, 'radio', { name: /MySQL/, checked: true }) as HTMLInputElement).checked).toBe(true) + + // now the database config fields are visible + await expect(component.findByRole('group', { name: /Database connection/ })).resolves.not.toThrow() + // but not the Database tablespace + await expect(component.findByRole('textbox', { name: /Database tablespace/ })).rejects.toThrow() + + // Change to Oracle + await fireEvent.click(getByRole(dbTypes, 'radio', { name: /Oracle/, checked: false })) + + // see database config fields are visible and tablespace + await expect(component.findByRole('textbox', { name: /Database tablespace/ })).resolves.not.toThrow() + await expect(component.findByRole('group', { name: /Database connection/ })).resolves.not.toThrow() + }) +}) + +describe('Setup page with errors and warning', () => { + beforeEach(cleanup) + beforeEach(() => { + removeInitialState() + mockInitialState('core', 'links', links) + }) + + it('Renders error from backend', async () => { + mockInitialState('core', 'config', { + ...defaultConfig, + errors: [ + { + error: 'Error message', + hint: 'Error hint', + }, + ], + }) + const component = render(SetupView) + + // Error message and hint + await expect(component.findByText('Error message')).resolves.not.toThrow() + await expect(component.findByText('Error hint')).resolves.not.toThrow() + }) + + it('Renders errors from backend', async () => { + const config = { + ...defaultConfig, + errors: [ + 'Error message 1', + { + error: 'Error message 2', + hint: 'Error hint', + }, + ], + } + mockInitialState('core', 'config', config) + const component = render(SetupView) + + // Error message and hint + await expect(component.findByText('Error message 1')).resolves.not.toThrow() + await expect(component.findByText('Error message 2')).resolves.not.toThrow() + await expect(component.findByText('Error hint')).resolves.not.toThrow() + }) + + it('Renders all the submitted fields on error', async () => { + const config = { + ...defaultConfig, + adminlogin: 'admin', + adminpass: 'password', + dbname: 'nextcloud', + dbtype: 'mysql', + dbuser: 'nextcloud', + dbpass: 'password', + dbhost: 'localhost', + directory: '/var/www/html/nextcloud', + } as SetupConfig + mockInitialState('core', 'config', config) + const component = render(SetupView) + + await expect(component.findByRole('textbox', { name: 'Data folder' })).resolves.not.toThrow() + expect((component.getByRole('textbox', { name: 'Data folder' }) as HTMLInputElement).value).toBe('/var/www/html/nextcloud') + + await expect(component.findByRole('textbox', { name: 'Administration account name' })).resolves.not.toThrow() + expect((component.getByRole('textbox', { name: 'Administration account name' }) as HTMLInputElement).value).toBe('admin') + + await expect(component.findByLabelText('Administration account password')).resolves.not.toThrow() + expect((component.getByLabelText('Administration account password') as HTMLInputElement).value).toBe('password') + + await expect(component.findByRole('radio', { name: /MySQL/, checked: true, hidden: true })).resolves.not.toThrow() + await expect(component.findByRole('textbox', { name: 'Database name' })).resolves.not.toThrow() + expect((component.getByRole('textbox', { name: 'Database name' }) as HTMLInputElement).value).toBe('nextcloud') + await expect(component.findByRole('textbox', { name: 'Database user' })).resolves.not.toThrow() + expect((component.getByRole('textbox', { name: 'Database user' }) as HTMLInputElement).value).toBe('nextcloud') + await expect(component.findByRole('textbox', { name: 'Database host' })).resolves.not.toThrow() + expect((component.getByRole('textbox', { name: 'Database host' }) as HTMLInputElement).value).toBe('localhost') + await expect(component.findByLabelText('Database password')).resolves.not.toThrow() + expect((component.getByLabelText('Database password') as HTMLInputElement).value).toBe('password') + }) + + it('Renders the htaccess warning', async () => { + const config = { + ...defaultConfig, + htaccessWorking: false, + } + mockInitialState('core', 'config', config) + const component = render(SetupView) + + await expect(component.findByText('Security warning')).resolves.not.toThrow() + }) +}) + +describe('Setup page with autoconfig', () => { + beforeEach(cleanup) + beforeEach(() => { + removeInitialState() + mockInitialState('core', 'links', links) + }) + + it('Renders autoconfig', async () => { + const config = { + ...defaultConfig, + hasAutoconfig: true, + dbname: 'nextcloud', + dbtype: 'mysql', + dbuser: 'nextcloud', + dbpass: 'password', + dbhost: 'localhost', + directory: '/var/www/html/nextcloud', + } as SetupConfig + mockInitialState('core', 'config', config) + const component = render(SetupView) + + // Autoconfig info note + await expect(component.findByText('Autoconfig file detected')).resolves.not.toThrow() + + // Oracle tablespace is hidden + await expect(component.findByRole('textbox', { name: 'Database tablespace' })).rejects.toThrow() + + // Database and storage section is hidden as already set in autoconfig + await expect(component.findByText('Storage & database')).resolves.not.toThrow() + expect(component.getByText('Storage & database').closest('details')!.getAttribute('hidden')).toBeNull() + }) +}) + +/** + * Remove the mocked initial state + */ +function removeInitialState(): void { + document.querySelectorAll('input[type="hidden"]').forEach((el) => { + el.remove() + }) + // clear the cache + delete globalThis._nc_initial_state +} + +/** + * Helper to mock an initial state value + * @param app - The app + * @param key - The key + * @param value - The value + */ +function mockInitialState(app: string, key: string, value: unknown): void { + const el = document.createElement('input') + el.value = btoa(JSON.stringify(value)) + el.id = `initial-state-${app}-${key}` + el.type = 'hidden' + + document.head.appendChild(el) +} diff --git a/core/src/views/Setup.vue b/core/src/views/Setup.vue index 259232a3efb..61034cb9919 100644 --- a/core/src/views/Setup.vue +++ b/core/src/views/Setup.vue @@ -89,6 +89,10 @@
+ + {{ t('core', 'Database type') }} + +

+ + {{ t('core', 'Database connection') }} + +