diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index c770c4a23ebc727d0bc16ad0ac219700c9c49127..cdb088de99d12f82d8e0dcfc3c64f77e22862fe1 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -44,6 +44,11 @@ import { mapMessageFromApi } from '../../../client/lib/utils/mapMessageFromApi'; let failedToDecodeKey = false; +type KeyPair = { + public_key: string | null; + private_key: string | null; +}; + class E2E extends Emitter { private started: boolean; @@ -114,19 +119,28 @@ class E2E extends Emitter { delete this.instancesByRoomId[rid]; } - async persistKeys(public_key: string | null, private_key: string | null): Promise<void> { + async persistKeys({ public_key, private_key }: KeyPair, password: string): Promise<void> { if (typeof public_key !== 'string' || typeof private_key !== 'string') { throw new Error('Failed to persist keys as they are not strings.'); } + const encodedPrivateKey = await this.encodePrivateKey(private_key, password); + + if (!encodedPrivateKey) { + throw new Error('Failed to encode private key with provided password.'); + } + await APIClient.post('/v1/e2e.setUserPublicAndPrivateKeys', { public_key, - private_key, + private_key: encodedPrivateKey, }); } - getKeysFromLocalStorage(): [public_key: string | null, private_key: string | null] { - return [Meteor._localStorage.getItem('public_key'), Meteor._localStorage.getItem('private_key')]; + getKeysFromLocalStorage(): KeyPair { + return { + public_key: Meteor._localStorage.getItem('public_key'), + private_key: Meteor._localStorage.getItem('private_key'), + }; } async startClient(): Promise<void> { @@ -138,9 +152,7 @@ class E2E extends Emitter { this.started = true; - const [localPublicKey, localPrivateKey] = this.getKeysFromLocalStorage(); - let public_key = localPublicKey; - let private_key = localPrivateKey; + let { public_key, private_key } = this.getKeysFromLocalStorage(); await this.loadKeysFromDB(); @@ -176,7 +188,7 @@ class E2E extends Emitter { } if (!this.db_public_key || !this.db_private_key) { - this.persistKeys(...this.getKeysFromLocalStorage()); + this.persistKeys(this.getKeysFromLocalStorage(), await this.createRandomPassword()); } const randomPassword = Meteor._localStorage.getItem('e2e.randomPassword'); @@ -229,7 +241,7 @@ class E2E extends Emitter { } async changePassword(newPassword: string): Promise<void> { - await this.persistKeys(...this.getKeysFromLocalStorage()); + await this.persistKeys(this.getKeysFromLocalStorage(), newPassword); if (Meteor._localStorage.getItem('e2e.randomPassword')) { Meteor._localStorage.setItem('e2e.randomPassword', newPassword); diff --git a/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.js b/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.js index d6c3f35dd963801f4c283c6f4f901dacf3934b91..cbc9b0fc5b3ec500a7efcc63152cc3cf8980ed82 100644 --- a/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.js +++ b/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.js @@ -230,6 +230,7 @@ export class CachedCollection extends Emitter { data.forEach((record) => { const newRecord = callbacks.run(`cachedCollection-loadFromServer-${this.name}`, record, 'changed'); this.collection.direct.upsert({ _id: newRecord._id }, omit(newRecord, '_id')); + callbacks.run(`cachedCollection-after-loadFromServer-${this.name}`, record, 'changed'); this.onSyncData('changed', newRecord); @@ -296,6 +297,7 @@ export class CachedCollection extends Emitter { const { _id, ...recordData } = newRecord; this.collection.direct.upsert({ _id }, recordData); } + callbacks.run(`cachedCollection-after-received-${this.name}`, record, t); this.save(); }); } @@ -362,6 +364,7 @@ export class CachedCollection extends Emitter { if (actionTime > this.updatedAt) { this.updatedAt = actionTime; } + callbacks.run(`cachedCollection-after-sync-${this.name}`, record, action); this.onSyncData(action, newRecord); } this.updatedAt = this.updatedAt === lastTime ? startTime : this.updatedAt; diff --git a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx index 32ed9924a3d7d20cbef5559ba06f3c74404bc67b..1b0f25716b51dc433d2f0a16d7906c6b281435a9 100644 --- a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx +++ b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx @@ -259,7 +259,13 @@ const CreateChannelModal = ({ teamId = '', onClose }: CreateChannelModalProps): control={control} name='encrypted' render={({ field: { onChange, value, ref } }): ReactElement => ( - <ToggleSwitch ref={ref} checked={value} disabled={e2eDisabled || federated} onChange={onChange} /> + <ToggleSwitch + ref={ref} + checked={value} + disabled={e2eDisabled || federated} + onChange={onChange} + data-qa-type='encryption-toggle' + /> )} /> </Box> diff --git a/apps/meteor/client/views/account/security/AccountSecurityPage.tsx b/apps/meteor/client/views/account/security/AccountSecurityPage.tsx index e6017b943d7ec97ebd9299c617f5b8f6745b833c..0c4c77112e68b6b749cf8da5e0131821f374e757 100644 --- a/apps/meteor/client/views/account/security/AccountSecurityPage.tsx +++ b/apps/meteor/client/views/account/security/AccountSecurityPage.tsx @@ -34,7 +34,7 @@ const AccountSecurityPage = (): ReactElement => { </Accordion.Item> )} {e2eEnabled && ( - <Accordion.Item title={t('E2E Encryption')} defaultExpanded={!twoFactorEnabled}> + <Accordion.Item title={t('E2E Encryption')} defaultExpanded={!twoFactorEnabled} data-qa-type='e2e-encryption-section'> <EndToEnd /> </Accordion.Item> )} diff --git a/apps/meteor/client/views/account/security/EndToEnd.tsx b/apps/meteor/client/views/account/security/EndToEnd.tsx index 283dd1bd757e39922e5364fc0a490080126c07b6..56df472c8f4fb963c1314fab2fc610ba9a9193ca 100644 --- a/apps/meteor/client/views/account/security/EndToEnd.tsx +++ b/apps/meteor/client/views/account/security/EndToEnd.tsx @@ -78,7 +78,13 @@ const EndToEnd = (props: ComponentProps<typeof Box>): ReactElement => { <Field> <Field.Label>{t('New_encryption_password')}</Field.Label> <Field.Row> - <PasswordInput value={password} onChange={handlePassword} placeholder={t('New_Password_Placeholder')} disabled={!keysExist} /> + <PasswordInput + value={password} + onChange={handlePassword} + placeholder={t('New_Password_Placeholder')} + disabled={!keysExist} + data-qa-type='e2e-encryption-password' + /> </Field.Row> {!keysExist && <Field.Hint>{t('EncryptionKey_Change_Disabled')}</Field.Hint>} </Field> @@ -90,19 +96,22 @@ const EndToEnd = (props: ComponentProps<typeof Box>): ReactElement => { value={passwordConfirm} onChange={handlePasswordConfirm} placeholder={t('Confirm_New_Password_Placeholder')} + data-qa-type='e2e-encryption-password-confirmation' /> <Field.Error>{passwordError}</Field.Error> </Field> )} </FieldGroup> - <Button primary disabled={!canSave} onClick={saveNewPassword}> + <Button primary disabled={!canSave} onClick={saveNewPassword} data-qa-type='e2e-encryption-save-password-button'> {t('Save_changes')} </Button> <Box fontScale='h4' mbs='x16'> {t('Reset_E2E_Key')} </Box> <Box dangerouslySetInnerHTML={{ __html: t('E2E_Reset_Key_Explanation') }} /> - <Button onClick={handleResetE2eKey}>{t('Reset_E2E_Key')}</Button> + <Button onClick={handleResetE2eKey} data-qa-type='e2e-encryption-reset-key-button'> + {t('Reset_E2E_Key')} + </Button> </Margins> </Box> ); diff --git a/apps/meteor/lib/callbacks.ts b/apps/meteor/lib/callbacks.ts index 1b20efdca6e82d739a91ccd064ac4d569591f951..f381c9b9fc3962a55249f5eabc819f1c12d4e851 100644 --- a/apps/meteor/lib/callbacks.ts +++ b/apps/meteor/lib/callbacks.ts @@ -177,6 +177,12 @@ type Hook = | 'cachedCollection-received-subscriptions' | 'cachedCollection-sync-rooms' | 'cachedCollection-sync-subscriptions' + | 'cachedCollection-after-loadFromServer-rooms' + | 'cachedCollection-after-loadFromServer-subscriptions' + | 'cachedCollection-after-received-rooms' + | 'cachedCollection-after-received-subscriptions' + | 'cachedCollection-after-sync-rooms' + | 'cachedCollection-after-sync-subscriptions' | 'enter-room' | 'livechat.beforeForwardRoomToDepartment' | 'livechat.beforeInquiry' diff --git a/apps/meteor/tests/e2e/README.md b/apps/meteor/tests/e2e/README.md index ea47c2bf1b3f783df3291d11f1f878973505a171..28d6c440eaaad6220566390894b471d04bb66715 100644 --- a/apps/meteor/tests/e2e/README.md +++ b/apps/meteor/tests/e2e/README.md @@ -21,7 +21,7 @@ $ yarn test:e2e We can also provide some env vars to `test:e2e` script: - `BASE_URL=<any_url>` Run the tests to the given url -- `PWDEBUG=1` Controll the test execution +- `PWDEBUG=1` Control the test execution ## Page Objects - Any locator name must start with of one the following prefixes: `btn`, `link`, `input`, `select`, `checkbox`, `text` diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..74483a058842985490d9dca9a3155efefbf77371 --- /dev/null +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -0,0 +1,199 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { test, expect } from './utils/test'; +import { AccountProfile, HomeChannel } from './page-objects'; +import * as constants from './config/constants'; + +// OK Enable e2ee on admin +// OK Test banner and check password, logout and use password +// OK Set new password, logout and use the password +// OK Reset key, should logout, login and check banner +// OK Create channel encrypted and send message +// OK Disable encryption and send message +// OK Enable encryption and send message +// OK Create channel not encrypted, encrypt end send message + +async function login(page: Page): Promise<void> { + // TODO: Reuse code from global-setup.ts file + await page.locator('[name=username]').type(constants.ADMIN_CREDENTIALS.email); + await page.locator('[name=password]').type(constants.ADMIN_CREDENTIALS.password); + await page.locator('role=button >> text="Login"').click(); + await page.waitForTimeout(1000); + + await page.context().storageState({ path: `admin-session.json` }); +} + +test.use({ storageState: 'admin-session.json' }); + +test.describe.serial('e2e-encryption initial setup', () => { + let poAccountProfile: AccountProfile; + let poHomeChannel: HomeChannel; + let password: string; + + test.beforeEach(async ({ page }) => { + poAccountProfile = new AccountProfile(page); + poHomeChannel = new HomeChannel(page); + + await page.goto('/account/security'); + }); + + test.beforeAll(async ({ api }) => { + const statusCode = (await api.post('/settings/E2E_Enable', { value: true })).status(); + + expect(statusCode).toBe(200); + }); + + test("expect reset user's e2e encryption key", async ({ page }) => { + // Reset key to start the flow from the beginning + // It will execute a logout + await poAccountProfile.securityE2EEncryptionSection.click(); + await poAccountProfile.securityE2EEncryptionResetKeyButton.click(); + + // Login again, check the banner to save the generated password and test it + await login(page); + await page.locator('role=banner >> text="Save Your Encryption Password"').click(); + + password = (await page.evaluate(() => localStorage.getItem('e2e.randomPassword'))) || 'undefined'; + + await expect(page.locator('#modal-root')).toContainText(password); + + await page.locator('#modal-root .rcx-button--primary').click(); + + await expect(page.locator('role=banner >> text="Save Your Encryption Password"')).not.toBeVisible(); + + await poHomeChannel.sidenav.logout(); + + await login(page); + + await page.locator('role=banner >> text="Enter your E2E password"').click(); + + await page.locator('#modal-root input').type(password); + + await page.locator('#modal-root .rcx-button--primary').click(); + + await expect(page.locator('role=banner')).not.toBeVisible(); + + // Store the generated key + await page.context().storageState({ path: `admin-session.json` }); + }); + + test('expect change the e2ee password', async ({ page }) => { + // Change the password to a new one and test it + const newPassword = 'new password'; + + await poAccountProfile.securityE2EEncryptionSection.click(); + await poAccountProfile.securityE2EEncryptionPassword.click(); + await poAccountProfile.securityE2EEncryptionPassword.type(newPassword); + await poAccountProfile.securityE2EEncryptionPasswordConfirmation.type(newPassword); + await poAccountProfile.securityE2EEncryptionSavePasswordButton.click(); + + await poAccountProfile.btnClose.click(); + + await poHomeChannel.sidenav.logout(); + + await login(page); + + await page.locator('role=banner >> text="Enter your E2E password"').click(); + + await page.locator('#modal-root input').type(password); + + await page.locator('#modal-root .rcx-button--primary').click(); + + await page.locator('role=banner >> text="Wasn\'t possible to decode your encryption key to be imported."').click(); + + await page.locator('#modal-root input').type(newPassword); + + await page.locator('#modal-root .rcx-button--primary').click(); + + await expect(page.locator('role=banner')).not.toBeVisible(); + + // Store the current key + await page.context().storageState({ path: `admin-session.json` }); + }); +}); + +test.describe.serial('e2e-encryption', () => { + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await page.goto('/home'); + // TODO: remove + // await page.evaluate(() => localStorage.setItem('rc-config-debug', 'true')); + // TODO: remove block + // await page.locator('role=banner >> text="Enter your E2E password"').click(); + // await page.locator('#modal-root input').type('new password'); + // await page.locator('#modal-root .rcx-button--primary').click(); + // await expect(page.locator('role=banner')).not.toBeVisible(); + }); + + test('expect create a private channel encrypted and send an encrypted message', async ({ page }) => { + const channelName = faker.datatype.uuid(); + + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.type(channelName); + await poHomeChannel.sidenav.checkboxEncryption.click(); + await poHomeChannel.sidenav.btnCreate.click(); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.toastSuccess).toBeVisible(); + + await poHomeChannel.toastSuccess.locator('button >> i.rcx-icon--name-cross.rcx-icon').click(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('hello world'); + + await expect(poHomeChannel.content.lastUserMessage.locator('p')).toHaveText('hello world'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + await poHomeChannel.tabs.kebab.click({ force: true }); + await expect(poHomeChannel.tabs.btnE2E).toBeVisible(); + await poHomeChannel.tabs.btnE2E.click({ force: true }); + await page.waitForTimeout(1000); + + await poHomeChannel.content.sendMessage('hello world not encrypted'); + + await expect(poHomeChannel.content.lastUserMessage.locator('p')).toHaveText('hello world not encrypted'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); + + await poHomeChannel.tabs.kebab.click({ force: true }); + await expect(poHomeChannel.tabs.btnE2E).toBeVisible(); + await poHomeChannel.tabs.btnE2E.click({ force: true }); + await page.waitForTimeout(1000); + + await poHomeChannel.content.sendMessage('hello world encrypted again'); + + await expect(poHomeChannel.content.lastUserMessage.locator('p')).toHaveText('hello world encrypted again'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); + + test('expect create a private channel, encrypt it and send an encrypted message', async ({ page }) => { + const channelName = faker.datatype.uuid(); + + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.type(channelName); + await poHomeChannel.sidenav.btnCreate.click(); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.toastSuccess).toBeVisible(); + + await poHomeChannel.toastSuccess.locator('button >> i.rcx-icon--name-cross.rcx-icon').click(); + + await poHomeChannel.tabs.kebab.click({ force: true }); + await expect(poHomeChannel.tabs.btnE2E).toBeVisible(); + await poHomeChannel.tabs.btnE2E.click({ force: true }); + await page.waitForTimeout(1000); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('hello world'); + + await expect(poHomeChannel.content.lastUserMessage.locator('p')).toHaveText('hello world'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/page-objects/account-profile.ts b/apps/meteor/tests/e2e/page-objects/account-profile.ts index 3c2ed3087dec68fcd72be61aacafb99d0e8c4792..32545474c09c1a9dc245307edfd83080bfa13a80 100644 --- a/apps/meteor/tests/e2e/page-objects/account-profile.ts +++ b/apps/meteor/tests/e2e/page-objects/account-profile.ts @@ -41,7 +41,7 @@ export class AccountProfile { } get btnClose(): Locator { - return this.page.locator('button >> i.rcx-icon--name-cross.rcx-icon'); + return this.page.locator('aside[role="navigation"] button >> i.rcx-icon--name-cross.rcx-icon'); } get inputToken(): Locator { @@ -75,4 +75,24 @@ export class AccountProfile { get inputImageFile(): Locator { return this.page.locator('input[type=file]'); } + + get securityE2EEncryptionSection(): Locator { + return this.page.locator("[data-qa-type='e2e-encryption-section']"); + } + + get securityE2EEncryptionResetKeyButton(): Locator { + return this.page.locator("[data-qa-type='e2e-encryption-reset-key-button']"); + } + + get securityE2EEncryptionPassword(): Locator { + return this.page.locator("[data-qa-type='e2e-encryption-password']"); + } + + get securityE2EEncryptionPasswordConfirmation(): Locator { + return this.page.locator("[data-qa-type='e2e-encryption-password-confirmation']"); + } + + get securityE2EEncryptionSavePasswordButton(): Locator { + return this.page.locator("[data-qa-type='e2e-encryption-save-password-button']"); + } } diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index b64080bb9f3be2f77415f03c2fe28a153bfe2358..fd0a1ee834845a9cfe0d699c6a58a2aec26b10f8 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -25,6 +25,10 @@ export class HomeContent { return this.page.locator('[data-qa-type="message"][data-sequential="false"]').last(); } + get encryptedRoomHeaderIcon(): Locator { + return this.page.locator('.rcx-room-header button > i.rcx-icon--name-key'); + } + async sendMessage(text: string): Promise<void> { await this.page.locator('[name="msg"]').type(text); await this.page.keyboard.press('Enter'); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts index 0dd13b775aacfd647092ad353445bbe55561d44a..c50c31c06180c8ae4aa913ea8ddf09f94fb97cdb 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts @@ -44,6 +44,10 @@ export class HomeFlextab { return this.page.locator('[data-qa-id=ToolBoxAction-bell]'); } + get btnE2E(): Locator { + return this.page.locator('[data-qa-id=ToolBoxAction-key]'); + } + get flexTabViewThreadMessage(): Locator { return this.page .locator('div.thread-list ul.thread [data-qa-type="message"]') diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts index 200582a5a756c8f77a9156f2ec2735fa50fe4ccd..83bb0cc68840afdda445293053d52323d72a8208 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts @@ -10,9 +10,11 @@ export class HomeSidenav { } get checkboxPrivateChannel(): Locator { - return this.page.locator( - '//*[@id="modal-root"]//*[contains(@class, "rcx-field") and contains(text(), "Private")]/../following-sibling::label/i', - ); + return this.page.locator('#modal-root [data-qa="create-channel-modal"] [data-qa-type="channel-private-toggle"]'); + } + + get checkboxEncryption(): Locator { + return this.page.locator('#modal-root [data-qa="create-channel-modal"] [data-qa-type="encryption-toggle"]'); } get inputChannelName(): Locator {