Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ async function onSubmit() {
formElement.value?.reset()
emit('updated')
} catch (error) {
if (isAxiosError(error) && error.response && error.response.data?.data?.message) {
showError(error.response.data.data.message)
if (isAxiosError(error) && error.response?.data?.message) {
showError(error.response.data.message)
}
hasError.value = true
} finally {
Expand All @@ -67,13 +67,17 @@ async function onSubmit() {
{{ t('encryption', 'If you do not remember your old password you can ask your administrator to recover your files.') }}
</NcNoteCard>

<NcPasswordField :label="t('encryption', 'Old log-in password')" />
<NcPasswordField :label="t('encryption', 'Current log-in password')" />
<NcPasswordField
v-model="oldPrivateKeyPassword"
:label="t('encryption', 'Old log-in password')" />
<NcPasswordField
v-model="newPrivateKeyPassword"
:label="t('encryption', 'Current log-in password')" />

<NcButton
type="submit"
variant="primary">
{{ t('encryption', 'Update') }}
{{ t('encryption', 'Update') /* TRANSLATORS: Button label, when clicked the password will be updated */ }}
</NcButton>
</NcFormGroup>
</form>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { expect, test } from '../../support/fixtures/encryption-personal-settings-page.ts'
import {
desyncPrivateKeyPassword,
disableEncryption,
enablePerAccountKeyEncryption,
enableRecoveryKey,
setPasswordRecoveryForUser,
} from '../../support/utils/encryption.ts'
import { getToast } from '../../support/utils/toasts.ts'
import { loginAs } from '../../support/utils/users.ts'

const RECOVERY_KEY_PASSWORD = 'recovery-key-password'

/**
* The personal settings of the default encryption module.
*
* Server-side encryption is instance-wide state, so the file is named
* `admin-settings-*`: that is the pattern `playwright.config.ts` runs in its own
* serial project.
*/
test.describe('encryption: personal settings', () => {
// The accounts of all tests are created while encryption is enabled, so that
// they have encryption keys of their own.
test.beforeAll(enablePerAccountKeyEncryption)
test.afterAll(disableEncryption)

test.describe('outdated private key password', () => {
/** The password the private key of the current account is encrypted with. */
let privateKeyPassword: string

test.beforeEach(async ({ page, user, encryptionSettings }) => {
privateKeyPassword = await desyncPrivateKeyPassword(user)
await loginAs(page, user)
await encryptionSettings.open()
})

test('offers to update the private key password', async ({ encryptionSettings }) => {
await expect(encryptionSettings.updatePrivateKeyPasswordForm()).toBeVisible()
await expect(encryptionSettings.oldLoginPasswordField()).toBeVisible()
await expect(encryptionSettings.currentLoginPasswordField()).toBeVisible()
await expect(encryptionSettings.updateButton()).toBeVisible()
// Without password recovery there is no point in mentioning the administrator
await expect(encryptionSettings.recoveryHint()).toHaveCount(0)
})

test('points to the administrator if password recovery is enabled', async ({ user, encryptionSettings }) => {
await setPasswordRecoveryForUser(user.userId, true)
await encryptionSettings.open()

await expect(encryptionSettings.recoveryHint())
.toHaveText(/ask your administrator to recover your files/)
})

test('rejects a wrong current log-in password', async ({ page, encryptionSettings }) => {
const response = await encryptionSettings.updatePrivateKeyPassword(privateKeyPassword, 'not-the-log-in-password')

// Complaining about the current password means the old one was submitted, too
expect(response.status()).toBe(400)
await expect(getToast(page, 'The current log-in password was not correct, please try again.')).toBeVisible()

// The form keeps its values so that only the wrong password has to be fixed
await expect(encryptionSettings.updatePrivateKeyPasswordForm()).toBeVisible()
await expect(encryptionSettings.oldLoginPasswordField()).toHaveValue(privateKeyPassword)
await expect(encryptionSettings.currentLoginPasswordField()).toHaveValue('not-the-log-in-password')
})

test('rejects a wrong old log-in password', async ({ page, user, encryptionSettings }) => {
const response = await encryptionSettings.updatePrivateKeyPassword('not-the-old-password', user.password)

// Complaining about the old password means the current one was accepted
expect(response.status()).toBe(400)

Check failure on line 77 in tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts

View workflow job for this annotation

GitHub Actions / Playwright tests 1 / 8

[admin-settings] › tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts:73:3 › encryption: personal settings › outdated private key password › rejects a wrong old log-in password

1) [admin-settings] › tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts:73:3 › encryption: personal settings › outdated private key password › rejects a wrong old log-in password Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBe(expected) // Object.is equality Expected: 400 Received: 503 75 | 76 | // Complaining about the old password means the current one was accepted > 77 | expect(response.status()).toBe(400) | ^ 78 | await expect(getToast(page, 'The old password was not correct, please try again.')).toBeVisible() 79 | await expect(encryptionSettings.updatePrivateKeyPasswordForm()).toBeVisible() 80 | }) at /home/runner/actions-runner/_work/server/server/tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts:77:30

Check failure on line 77 in tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts

View workflow job for this annotation

GitHub Actions / merge-reports

[admin-settings] › tests/playwright/tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts:73:3 › encryption: personal settings › outdated private key password › rejects a wrong old log-in password

1) [admin-settings] › tests/playwright/tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts:73:3 › encryption: personal settings › outdated private key password › rejects a wrong old log-in password Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBe(expected) // Object.is equality Expected: 400 Received: 503 75 | 76 | // Complaining about the old password means the current one was accepted > 77 | expect(response.status()).toBe(400) | ^ 78 | await expect(getToast(page, 'The old password was not correct, please try again.')).toBeVisible() 79 | await expect(encryptionSettings.updatePrivateKeyPasswordForm()).toBeVisible() 80 | }) at /home/runner/actions-runner/_work/server/server/tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts:77:30

Check failure on line 77 in tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts

View workflow job for this annotation

GitHub Actions / merge-reports

[admin-settings] › tests/playwright/tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts:73:3 › encryption: personal settings › outdated private key password › rejects a wrong old log-in password

1) [admin-settings] › tests/playwright/tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts:73:3 › encryption: personal settings › outdated private key password › rejects a wrong old log-in password Error: expect(received).toBe(expected) // Object.is equality Expected: 400 Received: 503 75 | 76 | // Complaining about the old password means the current one was accepted > 77 | expect(response.status()).toBe(400) | ^ 78 | await expect(getToast(page, 'The old password was not correct, please try again.')).toBeVisible() 79 | await expect(encryptionSettings.updatePrivateKeyPasswordForm()).toBeVisible() 80 | }) at /home/runner/actions-runner/_work/server/server/tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts:77:30
await expect(getToast(page, 'The old password was not correct, please try again.')).toBeVisible()
await expect(encryptionSettings.updatePrivateKeyPasswordForm()).toBeVisible()
})

test('updates the private key password', async ({ page, user, encryptionSettings }) => {
const response = await encryptionSettings.updatePrivateKeyPassword(privateKeyPassword, user.password)

expect(response.status()).toBe(200)
// The settings reload the status, which is now successful
await expect(getToast(page, 'Encryption app is enabled and ready')).toBeVisible()
await expect(encryptionSettings.updatePrivateKeyPasswordForm()).toHaveCount(0)

// The private key is really encrypted with the log-in password now: a new
// session can unlock it, so there is nothing left to configure
await page.context().clearCookies()
await loginAs(page, user)
await encryptionSettings.open()
await expect(encryptionSettings.section()).toHaveCount(0)
})
})

test.describe('uninitialized keys', () => {
test.beforeEach(async ({ page, user, encryptionSettings }) => {
// Log in while encryption is switched off: the account keys are never
// unlocked for that session, the state accounts are in when encryption is
// enabled while they are logged in.
await runOcc(['encryption:disable'])
await loginAs(page, user)
await runOcc(['encryption:enable'])

await encryptionSettings.open()
})

test('asks to log in again', async ({ encryptionSettings }) => {
await expect(encryptionSettings.keysNotInitializedWarning())
.toHaveText(/please log-out and log-in again/)
await expect(encryptionSettings.updatePrivateKeyPasswordForm()).toHaveCount(0)
})
})

test.describe('password recovery', () => {
test.beforeEach(async ({ page, user, adminRequest, encryptionSettings }) => {
await loginAs(page, user)
await enableRecoveryKey(adminRequest, RECOVERY_KEY_PASSWORD)
await encryptionSettings.open()
})

// Leave the instance without a recovery key, otherwise the encryption section
// keeps being rendered for accounts that have nothing else to configure
test.afterAll(async () => {
await runOcc(['config:app:delete', 'encryption', 'recoveryAdminEnabled'])
})

test('can be enabled and disabled', async ({ encryptionSettings }) => {
await expect(encryptionSettings.passwordRecoverySwitch()).not.toBeChecked()

await encryptionSettings.setPasswordRecovery(true)
await encryptionSettings.open()
await expect(encryptionSettings.passwordRecoverySwitch()).toBeChecked()

await encryptionSettings.setPasswordRecovery(false)
await encryptionSettings.open()
await expect(encryptionSettings.passwordRecoverySwitch()).not.toBeChecked()
})
})
})
30 changes: 30 additions & 0 deletions tests/playwright/support/fixtures/admin-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { APIRequestContext } from '@playwright/test'

import { test as baseTest } from '@playwright/test'

type AdminRequestFixtures = {
/**
* A request context authenticated as the administrator via basic auth, with no
* browser session cookies — needed because cookies of the page under test would
* otherwise win over basic auth and the request would run as that account.
*/
adminRequest: APIRequestContext
}

export const test = baseTest.extend<AdminRequestFixtures>({
adminRequest: async ({ playwright, baseURL }, use) => {
const context = await playwright.request.newContext({
baseURL,
// send: 'always' — Nextcloud does not issue a Basic auth challenge for
// app routes, so the credentials must be sent preemptively
httpCredentials: { username: 'admin', password: 'admin', send: 'always' },
})
await use(context)
await context.dispose()
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { mergeTests } from '@playwright/test'
import { EncryptionPersonalSettingsPage } from '../sections/EncryptionPersonalSettingsPage.ts'
import { test as adminRequestTest } from './admin-request.ts'
import { test as randomUserTest } from './random-user.ts'

type EncryptionFixtures = {
encryptionSettings: EncryptionPersonalSettingsPage
}

/**
* A random `user`, an `adminRequest` context and the personal encryption settings
* page object.
*
* The page is deliberately *not* logged in: the encryption app sets up and unlocks
* the account keys during log-in, so every test has to control when the session is
* created. Use `loginAs()` from `utils/users.ts` once the instance is prepared.
*/
export const test = mergeTests(randomUserTest, adminRequestTest).extend<EncryptionFixtures>({
encryptionSettings: async ({ page }, use) => {
await use(new EncryptionPersonalSettingsPage(page))
},
})

export { expect } from '../matchers.ts'
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { Locator, Page, Response } from '@playwright/test'

/**
* The "Basic encryption module" section of the personal security settings,
* provided by the encryption app.
*/
export class EncryptionPersonalSettingsPage {
constructor(private readonly page: Page) {}

/**
* The section container. It is only rendered when the encryption app has
* something for the account to do, so `toHaveCount(0)` asserts "nothing to do".
*/
section(): Locator {
// The container has no accessible name, so the app's mount point is used
return this.page.locator('#encryption-settings-section')
}

/** Warning shown when the account keys were never unlocked for this session. */
keysNotInitializedWarning(): Locator {
return this.section().getByRole('note').filter({ hasText: 'keys are not initialized' })
}

/** The "Update private key password" form, rendered as a labelled group. */
updatePrivateKeyPasswordForm(): Locator {
return this.page.getByRole('group', { name: 'Update private key password' })
}

oldLoginPasswordField(): Locator {
return this.updatePrivateKeyPasswordForm().getByLabel('Old log-in password')
}

currentLoginPasswordField(): Locator {
return this.updatePrivateKeyPasswordForm().getByLabel('Current log-in password')
}

updateButton(): Locator {
return this.updatePrivateKeyPasswordForm().getByRole('button', { name: 'Update' })
}

/** Hint to ask an administrator, only shown if password recovery is enabled. */
recoveryHint(): Locator {
return this.updatePrivateKeyPasswordForm().getByRole('note')
}

passwordRecoverySwitch(): Locator {
return this.section().getByRole('checkbox', { name: 'Enable password recovery' })
}

/**
* Open the personal security settings.
*
* Waits for a section of the page that is always there, so that assertions on
* the absence of the encryption section cannot pass before the page rendered.
*/
async open(): Promise<void> {
await this.page.goto('settings/user/security')
await this.page.getByRole('heading', { name: 'Devices & sessions' }).waitFor()
}

/**
* Fill in and submit the "Update private key password" form.
*
* @param oldPassword - Value for "Old log-in password"
* @param currentPassword - Value for "Current log-in password"
* @return The response of the update request
*/
async updatePrivateKeyPassword(oldPassword: string, currentPassword: string): Promise<Response> {
await this.oldLoginPasswordField().fill(oldPassword)
await this.currentLoginPasswordField().fill(currentPassword)

// Registered before the click, otherwise the response can be missed
const updated = this.page.waitForResponse((response) => response.url().includes('/apps/encryption/ajax/updatePrivateKeyPassword'))
await this.updateButton().click()
return await updated
}

/**
* Toggle password recovery and wait for the change to be stored.
*
* The switch is debounced, so the request starts about a second after the
* click; the real input is visually hidden and needs a forced action.
*
* @param enabled - Whether password recovery should be enabled
*/
async setPasswordRecovery(enabled: boolean): Promise<void> {
const saved = this.page.waitForResponse((response) => response.url().includes('/apps/encryption/ajax/userSetRecovery')
&& response.request().method() === 'POST')
await this.passwordRecoverySwitch().setChecked(enabled, { force: true })
await saved
}
}
Loading
Loading