0
0
Fork 0
mirror of https://github.com/nextcloud/server.git synced 2025-05-11 17:16:10 +00:00

feat(core): add setup cypress tests

Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
This commit is contained in:
skjnldsv 2025-03-13 18:05:08 +01:00
parent 2026dfd1b9
commit 0179cb4d8d
6 changed files with 451 additions and 54 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>
@ -398,6 +435,9 @@ form {
// Db select required styling
.setup-form__database-type-select {
display: flex;
&--vertical {
flex-direction: column;
}
}
}

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,