Merge pull request #51468 from nextcloud/feat/cypress-setup

feat(core): add setup cypress tests
This commit is contained in:
John Molakvoæ 2025-03-13 21:24:48 +01:00 committed by GitHub
commit 49e87ccbfc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 458 additions and 61 deletions

View file

@ -133,8 +133,6 @@ class SetupController {
if ($dbIsSet and $directoryIsSet and $adminAccountIsSet) {
$post['install'] = 'true';
}
$post['dbIsSet'] = $dbIsSet;
$post['directoryIsSet'] = $directoryIsSet;
return $post;
}

View file

@ -16,6 +16,7 @@ export type DbType = 'sqlite' | 'mysql' | 'pgsql' | 'oci'
export type SetupConfig = {
adminlogin: string
adminpass: string
directory: string
dbuser: string
dbpass: string
dbname: string
@ -23,15 +24,8 @@ export type SetupConfig = {
dbhost: string
dbtype: DbType | ''
hasSQLite: boolean
hasMySQL: boolean
hasPostgreSQL: boolean
hasOracle: boolean
databases: Record<DbType, string>
databases: Partial<Record<DbType, string>>
dbIsSet: boolean
directory: string
directoryIsSet: boolean
hasAutoconfig: boolean
htaccessWorking: boolean
serverRoot: string

View file

@ -31,7 +31,7 @@ const isNextcloudUrl = (url) => {
/**
* Check if a user was logged in but is now logged-out.
* If this is the case then the user will be forwarded to the login page.
* @returns {Promise<void>}
* @return {Promise<void>}
*/
async function checkLoginStatus() {
// skip if no logged in user
@ -66,7 +66,7 @@ async function checkLoginStatus() {
/**
* Clear all Browser storages connected to current origin.
* @returns {Promise<void>}
* @return {Promise<void>}
*/
export async function wipeBrowserStorages() {
try {

369
core/src/views/Setup.cy.ts Normal file
View file

@ -0,0 +1,369 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { SetupConfig, SetupLinks } from '../install'
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)
// No DB radio selectors if only sqlite
cy.get('[data-cy-setup-form-field^="dbtype"]')
.should('not.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 and data directory
cy.get('[data-cy-setup-form-field]')
.should('be.visible')
.should('have.length', 3)
})
it('Renders single DB mysql', () => {
const config = {
...defaultConfig,
databases: {
mysql: 'MySQL/MariaDB',
},
}
cy.mockInitialState('core', 'config', config)
cy.mount(SetupView)
// No DB radio selectors if only mysql
cy.get('[data-cy-setup-form-field^="dbtype"]')
.should('not.exist')
// 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 user,
// db password, db name and db host
cy.get('[data-cy-setup-form-field]')
.should('be.visible')
.should('have.length', 7)
})
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())
})
})

View file

@ -7,11 +7,13 @@
class="setup-form"
:class="{ 'setup-form--loading': loading }"
action=""
data-cy-setup-form
method="POST"
@submit="onSubmit">
<!-- Autoconfig info -->
<NcNoteCard v-if="config.hasAutoconfig"
:heading="t('core', 'Autoconfig file detected')"
data-cy-setup-form-note="autoconfig"
type="success">
{{ t('core', 'The setup form below is pre-filled with the values from the config file.') }}
</NcNoteCard>
@ -19,6 +21,7 @@
<!-- Htaccess warning -->
<NcNoteCard v-if="config.htaccessWorking === false"
:heading="t('core', 'Security warning')"
data-cy-setup-form-note="htaccess"
type="warning">
<p v-html="htaccessWarning" />
</NcNoteCard>
@ -27,6 +30,7 @@
<NcNoteCard v-for="(error, index) in errors"
:key="index"
:heading="error.heading"
data-cy-setup-form-note="error"
type="error">
{{ error.message }}
</NcNoteCard>
@ -38,12 +42,14 @@
<!-- Username -->
<NcTextField v-model="config.adminlogin"
:label="t('core', 'Administration account name')"
data-cy-setup-form-field="adminlogin"
name="adminlogin"
required />
<!-- Password -->
<NcPasswordField v-model="config.adminpass"
:label="t('core', 'Administration account password')"
data-cy-setup-form-field="adminpass"
name="adminpass"
required />
@ -54,18 +60,18 @@
</fieldset>
<!-- Autoconfig toggle -->
<details :open="!isValidAutoconfig">
<summary>{{ t('core', 'Advanced settings') }}</summary>
<details :open="!isValidAutoconfig" data-cy-setup-form-advanced-config>
<summary>{{ t('core', 'Storage & database') }}</summary>
<!-- Data folder -->
<fieldset class="setup-form__data-folder">
<legend>{{ t('core', 'Data folder') }}</legend>
<NcTextField v-model="config.directory"
:label="t('core', 'Data folder')"
:placeholder="config.serverRoot + '/data'"
required
autocomplete="off"
autocapitalize="none"
data-cy-setup-form-field="directory"
name="directory"
spellcheck="false" />
</fieldset>
@ -76,22 +82,22 @@
<!-- Database type select -->
<fieldset class="setup-form__database-type">
<legend>{{ t('core', 'Database type') }}</legend>
<p v-if="Object.keys(config.databases).length > 1" class="setup-form__database-type-select">
<p v-if="!firstAndOnlyDatabase" :class="`setup-form__database-type-select--${DBTypeGroupDirection}`" class="setup-form__database-type-select">
<NcCheckboxRadioSwitch v-for="(name, db) in config.databases"
:key="db"
v-model="config.dbtype"
:button-variant="true"
:data-cy-setup-form-field="`dbtype-${db}`"
:value="db"
:button-variant-grouped="DBTypeGroupDirection"
name="dbtype"
button-variant-grouped="horizontal"
type="radio">
{{ name }}
</NcCheckboxRadioSwitch>
</p>
<NcNoteCard v-else type="warning">
{{ t('core', 'Only {db} is available.', { db: Object.values(config.databases).at(0) }) }}<br>
<NcNoteCard v-else data-cy-setup-form-db-note="single-db" type="warning">
{{ t('core', 'Only {firstAndOnlyDatabase} is available.', { firstAndOnlyDatabase }) }}<br>
{{ t('core', 'Install and activate additional PHP modules to choose other database types.') }}<br>
<a :href="links.adminSourceInstall" target="_blank" rel="noreferrer noopener">
{{ t('core', 'For more details check out the documentation.') }}
@ -100,6 +106,7 @@
<NcNoteCard v-if="config.dbtype === 'sqlite'"
:heading="t('core', 'Performance warning')"
data-cy-setup-form-db-note="sqlite"
type="warning">
{{ t('core', 'You chose SQLite as database.') }}<br>
{{ t('core', 'SQLite should only be used for minimal and development instances. For production we recommend a different database backend.') }}<br>
@ -113,6 +120,7 @@
:label="t('core', 'Database user')"
autocapitalize="none"
autocomplete="off"
data-cy-setup-form-field="dbuser"
name="dbuser"
spellcheck="false"
required />
@ -121,6 +129,7 @@
:label="t('core', 'Database password')"
autocapitalize="none"
autocomplete="off"
data-cy-setup-form-field="dbpass"
name="dbpass"
spellcheck="false"
required />
@ -129,6 +138,7 @@
:label="t('core', 'Database name')"
autocapitalize="none"
autocomplete="off"
data-cy-setup-form-field="dbname"
name="dbname"
pattern="[0-9a-zA-Z\$_\-]+"
spellcheck="false"
@ -139,6 +149,7 @@
:label="t('core', 'Database tablespace')"
autocapitalize="none"
autocomplete="off"
data-cy-setup-form-field="dbtablespace"
name="dbtablespace"
spellcheck="false" />
@ -148,6 +159,7 @@
:placeholder="t('core', 'localhost')"
autocapitalize="none"
autocomplete="off"
data-cy-setup-form-field="dbhost"
name="dbhost"
spellcheck="false" />
</fieldset>
@ -161,6 +173,7 @@
:loading="loading"
:wide="true"
alignment="center-reverse"
data-cy-setup-form-submit
native-type="submit"
type="primary">
<template #icon>
@ -171,7 +184,7 @@
</NcButton>
<!-- Help note -->
<NcNoteCard type="info">
<NcNoteCard data-cy-setup-form-note="help" type="info">
{{ t('core', 'Need help?') }}
<a target="_blank" rel="noreferrer noopener" :href="links.adminInstall">{{ t('core', 'See the documentation') }} </a>
</NcNoteCard>
@ -194,9 +207,6 @@ import NcTextField from '@nextcloud/vue/components/NcTextField'
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
const config = loadState<SetupConfig>('core', 'config')
const links = loadState<SetupLinks>('core', 'links')
enum PasswordStrength {
VeryWeak,
Weak,
@ -206,6 +216,24 @@ enum PasswordStrength {
ExtremelyStrong,
}
const checkPasswordEntropy = (password: string = ''): PasswordStrength => {
const uniqueCharacters = new Set(password)
const entropy = parseInt(Math.log2(Math.pow(parseInt(uniqueCharacters.size.toString()), password.length)).toFixed(2))
if (entropy < 16) {
return PasswordStrength.VeryWeak
} else if (entropy < 31) {
return PasswordStrength.Weak
} else if (entropy < 46) {
return PasswordStrength.Moderate
} else if (entropy < 61) {
return PasswordStrength.Strong
} else if (entropy < 76) {
return PasswordStrength.VeryStrong
}
return PasswordStrength.ExtremelyStrong
}
export default defineComponent({
name: 'Setup',
@ -221,14 +249,14 @@ export default defineComponent({
setup() {
return {
links,
t,
}
},
data() {
return {
config,
config: {} as SetupConfig,
links: {} as SetupLinks,
isValidAutoconfig: false,
loading: false,
}
@ -236,11 +264,11 @@ export default defineComponent({
computed: {
passwordHelperText(): string {
if (this.config.adminpass === '') {
if (this.config?.adminpass === '') {
return ''
}
const passwordStrength = this.checkPasswordEntropy(this.config.adminpass)
const passwordStrength = checkPasswordEntropy(this.config?.adminpass)
switch (passwordStrength) {
case PasswordStrength.VeryWeak:
return t('core', 'Password is too weak')
@ -259,21 +287,39 @@ export default defineComponent({
return t('core', 'Unknown password strength')
},
passwordHelperType() {
if (this.checkPasswordEntropy(this.config.adminpass) < PasswordStrength.Moderate) {
if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Moderate) {
return 'error'
}
if (this.checkPasswordEntropy(this.config.adminpass) < PasswordStrength.Strong) {
if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Strong) {
return 'warning'
}
return 'success'
},
firstAndOnlyDatabase(): string|null {
const dbNames = Object.values(this.config?.databases || {})
if (dbNames.length === 1) {
return dbNames[0]
}
return null
},
DBTypeGroupDirection() {
const databases = Object.keys(this.config?.databases || {})
// If we have more than 3 databases, we want to display them vertically
if (databases.length > 3) {
return 'vertical'
}
return 'horizontal'
},
htaccessWarning(): string {
// We use v-html, let's make sure we're safe
const message = [
t('core', 'Your data directory and files are probably accessible from the internet because the <code>.htaccess</code> file does not work.'),
t('core', 'For information how to properly configure your server, please {linkStart}see the documentation{linkEnd}', {
linkStart: '<a href="' + links.adminInstall + '" target="_blank" rel="noreferrer noopener">',
linkStart: '<a href="' + this.links.adminInstall + '" target="_blank" rel="noreferrer noopener">',
linkEnd: '</a>',
}, { escape: false }),
].join('<br>')
@ -281,7 +327,7 @@ export default defineComponent({
},
errors() {
return this.config.errors.map(error => {
return (this.config?.errors || []).map(error => {
if (typeof error === 'string') {
return {
heading: '',
@ -305,7 +351,16 @@ export default defineComponent({
},
},
beforeMount() {
// Needs to only read the state once we're mounted
// for Cypress to be properly initialized.
this.config = loadState<SetupConfig>('core', 'config')
this.links = loadState<SetupLinks>('core', 'links')
},
mounted() {
// Set the first database type as default if none is set
if (this.config.dbtype === '') {
this.config.dbtype = Object.keys(this.config.databases).at(0) as DbType
}
@ -337,24 +392,6 @@ export default defineComponent({
async onSubmit() {
this.loading = true
},
checkPasswordEntropy(password: string): PasswordStrength {
const uniqueCharacters = new Set(password)
const entropy = parseInt(Math.log2(Math.pow(parseInt(uniqueCharacters.size.toString()), password.length)).toFixed(2))
if (entropy < 16) {
return PasswordStrength.VeryWeak
} else if (entropy < 31) {
return PasswordStrength.Weak
} else if (entropy < 46) {
return PasswordStrength.Moderate
} else if (entropy < 61) {
return PasswordStrength.Strong
} else if (entropy < 76) {
return PasswordStrength.VeryStrong
}
return PasswordStrength.ExtremelyStrong
},
},
})
</script>
@ -380,8 +417,8 @@ form {
margin-bottom: 0;
}
> fieldset,
> details {
fieldset,
details {
margin-block: 1rem;
}
@ -398,6 +435,9 @@ form {
// Db select required styling
.setup-form__database-type-select {
display: flex;
&--vertical {
flex-direction: column;
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -205,10 +205,6 @@ class Setup {
}
return [
'hasSQLite' => isset($databases['sqlite']),
'hasMySQL' => isset($databases['mysql']),
'hasPostgreSQL' => isset($databases['pgsql']),
'hasOracle' => isset($databases['oci']),
'databases' => $databases,
'directory' => $dataDir,
'htaccessWorking' => $htAccessWorking,