diff --git a/apps/encryption/src/components/SettingsPersonalChangePrivateKey.vue b/apps/encryption/src/components/SettingsPersonalChangePrivateKey.vue index 6631b17d12ffc..5d06dac5fb442 100644 --- a/apps/encryption/src/components/SettingsPersonalChangePrivateKey.vue +++ b/apps/encryption/src/components/SettingsPersonalChangePrivateKey.vue @@ -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 { @@ -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.') }} - - + + - {{ t('encryption', 'Update') }} + {{ t('encryption', 'Update') /* TRANSLATORS: Button label, when clicked the password will be updated */ }} diff --git a/tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts b/tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts new file mode 100644 index 0000000000000..97eaa51dda792 --- /dev/null +++ b/tests/playwright/e2e/encryption/admin-settings-personal-encryption.spec.ts @@ -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) + 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() + }) + }) +}) diff --git a/tests/playwright/support/fixtures/admin-request.ts b/tests/playwright/support/fixtures/admin-request.ts new file mode 100644 index 0000000000000..cc38125dfee21 --- /dev/null +++ b/tests/playwright/support/fixtures/admin-request.ts @@ -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({ + 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() + }, +}) diff --git a/tests/playwright/support/fixtures/encryption-personal-settings-page.ts b/tests/playwright/support/fixtures/encryption-personal-settings-page.ts new file mode 100644 index 0000000000000..e7fb909022315 --- /dev/null +++ b/tests/playwright/support/fixtures/encryption-personal-settings-page.ts @@ -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({ + encryptionSettings: async ({ page }, use) => { + await use(new EncryptionPersonalSettingsPage(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/sections/EncryptionPersonalSettingsPage.ts b/tests/playwright/support/sections/EncryptionPersonalSettingsPage.ts new file mode 100644 index 0000000000000..ad8e1c119a575 --- /dev/null +++ b/tests/playwright/support/sections/EncryptionPersonalSettingsPage.ts @@ -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 { + 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 { + 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 { + 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 + } +} diff --git a/tests/playwright/support/utils/encryption.ts b/tests/playwright/support/utils/encryption.ts new file mode 100644 index 0000000000000..9fb6cef798ac6 --- /dev/null +++ b/tests/playwright/support/utils/encryption.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext } from '@playwright/test' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { expect } from '@playwright/test' + +/** Log-in password set by {@link desyncPrivateKeyPassword}. */ +export const UPDATED_LOGIN_PASSWORD = 'updated-log-in-password' + +/** + * Turn on server-side encryption with per-account keys. + * + * Only without the master key does an account have a private key password of its + * own, which is what the personal encryption settings manage. Home storages are + * left unencrypted: the settings do not depend on it, and no test data has to be + * decrypted again when encryption is switched off in {@link disableEncryption}. + */ +export async function enablePerAccountKeyEncryption(): Promise { + await runOcc(['app:enable', 'encryption']) + await runOcc(['config:app:set', 'encryption', 'useMasterKey', '--value', '0', '--type', 'boolean']) + await runOcc(['config:app:set', 'encryption', 'encryptHomeStorage', '--value', '0', '--type', 'boolean']) + await runOcc(['encryption:enable']) +} + +/** + * Switch server-side encryption off again and restore the default configuration. + */ +export async function disableEncryption(): Promise { + await runOcc(['encryption:disable']) + await runOcc(['config:app:delete', 'encryption', 'recoveryAdminEnabled']) + await runOcc(['config:app:delete', 'encryption', 'encryptHomeStorage']) + await runOcc(['config:app:delete', 'encryption', 'useMasterKey']) + await runOcc(['app:disable', 'encryption']) +} + +/** + * Make an account's private key password differ from its log-in password — the + * state the "Update private key password" form exists for. This happens in real + * life when the password is changed in an external user backend. + * + * The account keys must already exist, meaning the account must have been created + * while encryption was enabled. The encryption app only re-encrypts the private + * key while encryption is enabled, so the log-in password is changed with + * encryption switched off and `user.password` is updated to the new one. + * + * The session must be created *after* this call, otherwise the encryption app + * already unlocked the private key for it. + * + * @param user - Account to desync — its `password` is updated in place + * @return The password the private key is still encrypted with + */ +export async function desyncPrivateKeyPassword(user: User): Promise { + const privateKeyPassword = user.password + + await runOcc(['encryption:disable']) + await runOcc(['user:resetpassword', user.userId, '--password-from-env'], { + env: [`OC_PASS=${UPDATED_LOGIN_PASSWORD}`], + }) + await runOcc(['encryption:enable']) + + user.password = UPDATED_LOGIN_PASSWORD + return privateKeyPassword +} + +/** + * Enable the recovery key, which is what makes the "Enable password recovery" + * setting available to accounts. The key is created on the first call, later + * calls have to pass the same password again. + * + * @param request - Request context authenticated as an administrator + * @param recoveryPassword - Password protecting the recovery key + */ +export async function enableRecoveryKey(request: APIRequestContext, recoveryPassword: string): Promise { + const response = await request.post('./apps/encryption/ajax/adminRecovery', { + // Marks the request as an API request, so no CSRF token is required + headers: { 'OCS-APIRequest': 'true' }, + data: { + recoveryPassword, + confirmPassword: recoveryPassword, + adminEnableRecovery: true, + }, + }) + expect(response.status(), await response.text()).toBe(200) +} + +/** + * Set the account's own "Enable password recovery" preference. + * + * @param userId - The account to change the preference for + * @param enabled - Whether password recovery should be enabled + */ +export async function setPasswordRecoveryForUser(userId: string, enabled: boolean): Promise { + await runOcc(['user:setting', userId, 'encryption', 'recoveryEnabled', enabled ? '1' : '0']) +} diff --git a/tests/playwright/support/utils/toasts.ts b/tests/playwright/support/utils/toasts.ts new file mode 100644 index 0000000000000..38632b490b045 --- /dev/null +++ b/tests/playwright/support/utils/toasts.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +/** + * A toast message shown by `@nextcloud/dialogs`. + * + * Toasts are appended to the document body, so they cannot be scoped to a page + * object's container, and they carry no accessible name — the toastify class is + * the only stable handle. + * + * @param page - The page showing the toast + * @param message - Text the toast must contain + * @return Locator matching all toasts containing `message` + */ +export function getToast(page: Page, message: string | RegExp): Locator { + return page.locator('.toastify').filter({ hasText: message }) +} diff --git a/tests/playwright/support/utils/users.ts b/tests/playwright/support/utils/users.ts index 5da4707fa9b7f..07a57d29d901f 100644 --- a/tests/playwright/support/utils/users.ts +++ b/tests/playwright/support/utils/users.ts @@ -3,7 +3,30 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { APIRequestContext } from '@playwright/test' +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext, Page } from '@playwright/test' + +import { login } from '@nextcloud/e2e-test-server/playwright' + +/** + * Log the browser context of `page` in as `user`. + * + * Unlike the `random-user-session` fixture this is called from the test itself, + * for cases where instance state has to be prepared *before* the session is + * created — the encryption app for example unlocks the account keys during log-in. + * + * @param page - Page whose browser context should hold the session + * @param user - The account to log in as + */ +export async function loginAs(page: Page, user: User): Promise { + try { + await login(page.request, user) + } catch (error) { + console.info('Failed to authenticate, retrying', error) + await new Promise((resolve) => setTimeout(resolve, 800)) + await login(page.request, user) + } +} /** * Grant subadmin rights to `subadminId` over `groupId` via the OCS Provisioning API.