diff --git a/.woodpecker.env b/.woodpecker.env index 175546f6fe0..b2274518e8b 100644 --- a/.woodpecker.env +++ b/.woodpecker.env @@ -1,3 +1,3 @@ # The version of OpenCloud to use in pipelines -OPENCLOUD_COMMITID=a3c7b2dbb396d7198b4d5682b917f1bbd36ca6da +OPENCLOUD_COMMITID=5b2529602fb22e1f5e37bf7879ffd132697bdc77 OPENCLOUD_BRANCH=main diff --git a/tests/e2e/features/rclone-crypt/spaceVault.feature b/tests/e2e/features/rclone-crypt/spaceVault.feature new file mode 100644 index 00000000000..05d4abd3b62 --- /dev/null +++ b/tests/e2e/features/rclone-crypt/spaceVault.feature @@ -0,0 +1,93 @@ +Feature: Work with an rclone-crypt encrypted vault space + As a user with an end-to-end encrypted project space + I want to unlock it and work with its files under cleartext names + So that I can collaborate on encrypted content without decrypting it manually + We check that when uploading files or editing them, the payload sent to the server is encrypted + + Background: + Given "Admin" creates following users using API + | id | + | Alice | + | Brian | + And "Admin" assigns following role to the users using API + | id | role | + | Alice | Space Admin | + + @rclone-crypt + Scenario: Create a vault space, work with its files and share it with a space member + When "Alice" logs in + And "Alice" navigates to the projects space page + And "Alice" creates the following project spaces + | name | password | + | vaultspace | foobar | + And "Alice" enters the vault space "vaultspace" with passphrase "foobar" + And "Alice" creates the following resources + | resource | type | content | password | + | hello.txt | txtFile | hello world | foobar | + And "Alice" uploads the following resource + | resource | password | + | testavatar.png | foobar | + Then following resources should be displayed in the files list for user "Alice" + | resource | + | hello.txt | + | testavatar.png | + And "Alice" should not be able to share following resources from the space "vaultspace" + | resource | + | hello.txt | + | testavatar.png | + When "Alice" opens the following file in texteditor + | resource | + | hello.txt | + Then "Alice" should see the content "hello world" in editor "TextEditor" + And "Alice" closes the file viewer + When "Alice" opens the following file in mediaviewer + | resource | + | testavatar.png | + Then "Alice" is in a media-viewer + And "Alice" closes the file viewer + When "Alice" navigates to the project space "vaultspace" + And "Alice" adds following user to the project space + | user | role | kind | + | Brian | Can edit | user | + And "Alice" logs out + + When "Brian" logs in + And "Brian" enters the vault space "vaultspace" with passphrase "foobar" + Then following resources should be displayed in the files list for user "Brian" + | resource | + | hello.txt | + | testavatar.png | + And "Brian" should not be able to share following resources from the space "vaultspace" + | resource | + | hello.txt | + | testavatar.png | + When "Brian" opens the following file in texteditor + | resource | + | hello.txt | + Then "Brian" should see the content "hello world" in editor "TextEditor" + And "Brian" closes the file viewer + When "Brian" opens the following file in mediaviewer + | resource | + | testavatar.png | + Then "Brian" is in a media-viewer + And "Brian" closes the file viewer + And "Brian" logs out + + @rclone-crypt + Scenario: Reloading an unlocked vault space locks it again + When "Alice" logs in + And "Alice" navigates to the projects space page + And "Alice" creates the following project spaces + | name | password | + | vaultspace | foobar | + And "Alice" enters the vault space "vaultspace" with passphrase "foobar" + And "Alice" creates the following resources + | resource | type | content | password | + | hello.txt | txtFile | hello world | foobar | + And "Alice" reloads the page + Then "Alice" should see the unlock page of the vault space "vaultspace" + When "Alice" unlocks the vault space with passphrase "foobar" + Then following resource should be displayed in the files list for user "Alice" + | resource | + | hello.txt | + And "Alice" logs out diff --git a/tests/e2e/steps/ui/resources.ts b/tests/e2e/steps/ui/resources.ts index e5c2d8cd122..f35f99bd594 100644 --- a/tests/e2e/steps/ui/resources.ts +++ b/tests/e2e/steps/ui/resources.ts @@ -418,6 +418,25 @@ Then( } ) +Then( + '{string} should not be able to share following resource(s) from the space {string}', + async ( + { world }: { world: World }, + stepUser: string, + space: string, + stepTable: DataTable + ): Promise => { + const { page } = world.actorsEnvironment.getActor({ key: stepUser }) + const spacesObject = new objects.applicationFiles.Spaces({ page }) + const resourceObject = new objects.applicationFiles.Resource({ page }) + + await spacesObject.expectOpen({ key: space }) + for (const info of stepTable.hashes()) { + await resourceObject.expectNotShareable({ resource: info.resource }) + } + } +) + Then( /^following resources? (should|should not) be displayed in the search list for user "([^"]*)"$/, async ( diff --git a/tests/e2e/steps/ui/spaces.ts b/tests/e2e/steps/ui/spaces.ts index 7d01c23b4f0..52191877759 100644 --- a/tests/e2e/steps/ui/spaces.ts +++ b/tests/e2e/steps/ui/spaces.ts @@ -3,7 +3,6 @@ import { DataTable } from 'playwright-bdd' import { expect } from '@playwright/test' import { World } from '../../environment/world' import { objects } from '../../support' -import { Space } from '../../support/types' When( '{string} navigates to the personal space page', @@ -34,7 +33,10 @@ When( const spacesObject = new objects.applicationFiles.Spaces({ page }) for (const space of stepTable.hashes()) { - await spacesObject.create({ key: space.id || space.name, space: space as unknown as Space }) + await spacesObject.create({ + key: space.id || space.name, + space: { name: space.name, password: space.password } + }) } } ) @@ -50,6 +52,44 @@ When( } ) +When( + '{string} enters the vault space {string} with passphrase {string}', + async function ( + { world }: { world: World }, + stepUser: string, + key: string, + passphrase: string + ): Promise { + const { page } = world.actorsEnvironment.getActor({ key: stepUser }) + const spacesObject = new objects.applicationFiles.Spaces({ page }) + const pageObject = new objects.applicationFiles.page.spaces.Projects({ page }) + await pageObject.navigate() + await spacesObject.openVault({ key, passphrase }) + } +) + +When( + '{string} unlocks the vault space with passphrase {string}', + async function ( + { world }: { world: World }, + stepUser: string, + passphrase: string + ): Promise { + const { page } = world.actorsEnvironment.getActor({ key: stepUser }) + const spacesObject = new objects.applicationFiles.Spaces({ page }) + await spacesObject.unlockVault({ passphrase }) + } +) + +Then( + '{string} should see the unlock page of the vault space {string}', + async function ({ world }: { world: World }, stepUser: string, key: string): Promise { + const { page } = world.actorsEnvironment.getActor({ key: stepUser }) + const spacesObject = new objects.applicationFiles.Spaces({ page }) + await spacesObject.expectVaultLocked({ key }) + } +) + When( /^"([^"]*)" (?:changes|updates) the space "([^"]*)" (name|subtitle|description|quota|image|icon) to "([^"]*)"$/, async function ( diff --git a/tests/e2e/support/objects/app-files/resource/actions.ts b/tests/e2e/support/objects/app-files/resource/actions.ts index 9690cf946f0..6c570f15461 100644 --- a/tests/e2e/support/objects/app-files/resource/actions.ts +++ b/tests/e2e/support/objects/app-files/resource/actions.ts @@ -171,6 +171,13 @@ const uploadList = '#upload-list' const encryptFolderSwitch = '[data-testid="create-folder-encrypt"] [data-testid="oc-switch-btn"]' const vaultSetupPassphraseInput = '#vault-setup-passphrase' const unlockVaultBtn = '#vault-unlock-submit' +const vaultPassphraseInput = '#vault-passphrase' +const filesContextMenu = 'div[id^="context-menu-drop"]' +const showSharesActionSelector = 'button.oc-files-actions-show-shares-trigger' +const quickActionShareButton = + '//*[@data-test-resource-name="%s"]/ancestor::tr//button[contains(@class, "files-quick-action-show-shares")]' +const inviteCollaboratorForm = '#new-collaborators-form' +const addPublicLinkButton = '#files-file-link-add' export const getResourceLocator = ({ page, @@ -2770,10 +2777,65 @@ const unlockVault = async ({ }): Promise => { const unlockButton = page.locator(unlockVaultBtn) await expect(unlockButton).toBeDisabled() - await page.locator('#vault-passphrase').fill(passphrase) + await page.locator(vaultPassphraseInput).fill(passphrase) await unlockButton.click() } +/** + * Navigate back to where a step started. If the start is a vault, it needs + * to be unlocked because the vault gets locked initially after a reload. + */ +export const returnToStartUrl = async ({ + page, + startUrl, + password +}: { + page: Page + startUrl: string + password?: string +}): Promise => { + await page.goto(startUrl) + if (!password) { + return + } + const passphraseInput = page.locator(vaultPassphraseInput) + try { + // Whichever of the two renders first says where the load landed: the + // unlock page for a locked vault, the file list for anything else. + await expect(passphraseInput.or(page.locator(filesView))).toBeVisible() + } catch { + return + } + if (!(await passphraseInput.isVisible())) { + return + } + await unlockVault({ page, passphrase: password }) + await expect(page.locator(appLoadingSpinner)).toBeHidden() +} + +export const expectResourceNotShareable = async ({ + page, + resource +}: { + page: Page + resource: string +}): Promise => { + await expect(page.locator(util.format(quickActionShareButton, resource))).toBeHidden() + + await sidebar.open({ page, resource }) + await sidebar.openPanel({ page, name: 'sharing' }) + await expect(page.locator(inviteCollaboratorForm)).toBeHidden() + await expect(page.locator(addPublicLinkButton)).toBeHidden() + await sidebar.close({ page }) + + await page.locator(util.format(resourceNameSelector, resource)).click({ button: 'right' }) + const contextMenu = page.locator(filesContextMenu) + await expect(contextMenu).toBeVisible() + await expect(contextMenu.locator(showSharesActionSelector)).toBeHidden() + await page.keyboard.press('Escape') + await expect(contextMenu).toBeHidden() +} + export const lockVault = async ({ page, vault }: { page: Page; vault: string }): Promise => { await page.locator(util.format(resourceNameSelector, vault)).click({ button: 'right' }) await page.locator(filesContextLockVaultAction).click() diff --git a/tests/e2e/support/objects/app-files/resource/index.ts b/tests/e2e/support/objects/app-files/resource/index.ts index 9afd75d7efd..88ee0895a35 100644 --- a/tests/e2e/support/objects/app-files/resource/index.ts +++ b/tests/e2e/support/objects/app-files/resource/index.ts @@ -14,13 +14,13 @@ export class Resource { async create(args: Omit): Promise { const startUrl = this.#page.url() await po.createResources({ ...args, page: this.#page }) - await this.#page.goto(startUrl) + await po.returnToStartUrl({ page: this.#page, startUrl, password: args.password }) } async upload(args: Omit): Promise { const startUrl = this.#page.url() await po.uploadResource({ ...args, page: this.#page }) - await this.#page.goto(startUrl) + await po.returnToStartUrl({ page: this.#page, startUrl, password: args.password }) } async tryToUpload(args: Omit): Promise { @@ -139,6 +139,10 @@ export class Resource { await this.#page.goto(startUrl) } + async expectNotShareable({ resource }: { resource: string }): Promise { + return await po.expectResourceNotShareable({ page: this.#page, resource }) + } + async expectThatDeleteTrashBinButtonIsNotVisible( args: Omit ): Promise { diff --git a/tests/e2e/support/objects/app-files/spaces/actions.ts b/tests/e2e/support/objects/app-files/spaces/actions.ts index 3576d9a0cb7..54b3b0ed037 100644 --- a/tests/e2e/support/objects/app-files/spaces/actions.ts +++ b/tests/e2e/support/objects/app-files/spaces/actions.ts @@ -27,6 +27,12 @@ const editSpacesDescription = '.oc-files-actions-edit-readme-content-trigger:vis const spacesDescriptionInputArea = '.text-editor-provider .ProseMirror' const spacesDescriptionSaveTextFileInEditorButton = '#app-save-action:visible' const spaceHeaderSelector = '.space-header' +const spaceHeaderNameSelector = '.space-header h2' +const encryptSpaceSwitch = '[data-testid="create-space-encrypt"] [data-testid="oc-switch-btn"]' +const vaultSetupPassphraseInput = '#vault-setup-passphrase' +const vaultPassphraseInput = '#vault-passphrase' +const vaultUnlockButton = '#vault-unlock-submit' +const vaultNameSelector = '[data-testid="vault-name"]' const activitySidebarPanel = 'sidebar-panel-activities' const activitySidebarPanelBodyContent = '#sidebar-panel-activities .sidebar-panel__body-content' @@ -50,25 +56,45 @@ export const openActivitiesPanel = async (page: Page): Promise => { export interface createSpaceArgs { name: string page: Page + password?: string } export const createSpace = async (args: createSpaceArgs): Promise => { - const { page, name } = args + const { page, name, password } = args await page.locator(newSpaceMenuButton).click() await page.locator(spaceNameInputField).fill(name) + // An encrypted space skips the default template, so the server never creates + // a `.space` folder for it. + const template = password ? 'none' : 'default' const postResponsePromise = page.waitForResponse( (postResp) => postResp.status() === 201 && postResp.request().method() === 'POST' && - postResp.url().endsWith('drives?template=default') + postResp.url().endsWith(`drives?template=${template}`) ) + if (!password) { + const [responses] = await Promise.all([ + postResponsePromise, + page.locator(actionConfirmButton).click() + ]) + const { id } = await responses.json() + return id + } + + await page.locator(encryptSpaceSwitch).click() + await page.locator(actionConfirmButton).click() + await page.locator(vaultSetupPassphraseInput).fill(password) + + // Committing the password writes the integrity token onto the new space root. + const proppatchPromise = page.waitForResponse((resp) => resp.request().method() === 'PROPPATCH') const [responses] = await Promise.all([ postResponsePromise, page.locator(actionConfirmButton).click() ]) + await proppatchPromise const { id } = await responses.json() return id @@ -76,6 +102,38 @@ export const createSpace = async (args: createSpaceArgs): Promise => { /**/ +export const unlockVaultSpace = async (args: { page: Page; passphrase: string }): Promise => { + const { page, passphrase } = args + const unlockButton = page.locator(vaultUnlockButton) + await expect(unlockButton).toBeDisabled() + await page.locator(vaultPassphraseInput).fill(passphrase) + await unlockButton.click() +} + +export const openVaultSpace = async (args: { + page: Page + id: string + passphrase: string +}): Promise => { + const { page, id, passphrase } = args + await page.locator(util.format(spaceIdSelector, id)).click() + await unlockVaultSpace({ page, passphrase }) + await page.locator(spaceHeaderSelector).waitFor() +} + +export const expectSpaceOpen = async (args: { page: Page; name: string }): Promise => { + const { page, name } = args + await expect(page.locator(spaceHeaderNameSelector)).toHaveText(name) +} + +export const expectVaultSpaceLocked = async (args: { page: Page; name: string }): Promise => { + const { page, name } = args + await expect(page.locator(vaultPassphraseInput)).toBeVisible() + await expect(page.locator(vaultNameSelector)).toHaveText(name) +} + +/**/ + export interface openSpaceArgs { id: string page: Page diff --git a/tests/e2e/support/objects/app-files/spaces/index.ts b/tests/e2e/support/objects/app-files/spaces/index.ts index 16a6d626e4b..6ac4b694f3b 100644 --- a/tests/e2e/support/objects/app-files/spaces/index.ts +++ b/tests/e2e/support/objects/app-files/spaces/index.ts @@ -37,6 +37,25 @@ export class Spaces { await po.openSpace({ page: this.#page, id }) } + async openVault({ key, passphrase }: { key: string; passphrase: string }): Promise { + const { id } = this.#spacesEnvironment.getSpace({ key }) + await po.openVaultSpace({ page: this.#page, id, passphrase }) + } + + async unlockVault({ passphrase }: { passphrase: string }): Promise { + await po.unlockVaultSpace({ page: this.#page, passphrase }) + } + + async expectOpen({ key }: { key: string }): Promise { + const { name } = this.#spacesEnvironment.getSpace({ key }) + await po.expectSpaceOpen({ page: this.#page, name }) + } + + async expectVaultLocked({ key }: { key: string }): Promise { + const { name } = this.#spacesEnvironment.getSpace({ key }) + await po.expectVaultSpaceLocked({ page: this.#page, name }) + } + async changeName({ key, value,