snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { Path } from '@rocket.chat/rest-typings';
import type { CallbackHandler, Response } from 'supertest';
import supertest from 'supertest';
import { adminUsername, adminPassword } from './user';
export const apiUrl = process.env.TEST_API_URL || 'http://localhost:3000';
export const request = supertest(apiUrl);
const prefix = '/api/v1/';
export function wait(cb: () => void, time: number) {
return () => setTimeout(cb, time);
}
const privateChannelName = `private-channel-test-${Date.now()}` as const;
const username = 'user.test';
const email = `${username}@rocket.chat`;
export const apiUsername = `api${username}-${Date.now()}` as const;
export const apiEmail = `api${email}-${Date.now()}` as const;
export const apiPrivateChannelName = `api${privateChannelName}-${Date.now()}` as const;
const roleNameUsers = `role-name-test-users-${Date.now()}` as const;
const roleNameSubscriptions = `role-name-test-subscriptions-${Date.now()}` as const;
const roleScopeUsers = 'Users' as const;
const roleScopeSubscriptions = 'Subscriptions' as const;
const roleDescription = `role-description-test-${Date.now()}` as const;
export const apiRoleNameUsers = `api${roleNameUsers}` as const;
export const apiRoleNameSubscriptions = `api${roleNameSubscriptions}` as const;
export const apiRoleScopeUsers = `${roleScopeUsers}` as const;
export const apiRoleScopeSubscriptions = `${roleScopeSubscriptions}` as const;
export const apiRoleDescription = `api${roleDescription}` as const;
export const reservedWords = ['admin', 'administrator', 'system', 'user'] as const;
export const credentials: Credentials = {
'X-Auth-Token': undefined,
'X-User-Id': undefined,
} as unknown as Credentials; // FIXME
export type PathWithoutPrefix<TPath> = TPath extends `/v1/${infer U}` ? U : never;
export function api<TPath extends PathWithoutPrefix<Path>>(path: TPath) {
return `${prefix}${path}` as const;
}
export function methodCall<TMethodName extends string>(methodName: TMethodName) {
return api(`method.call/${methodName}`);
}
export function methodCallAnon<TMethodName extends string>(methodName: TMethodName) {
return api(`method.callAnon/${methodName}`);
}
export function log(res: Response) {
console.log((res as { req?: any }).req.path); // FIXME
console.log({
body: res.body,
headers: res.headers,
});
}
let instanceId: string | undefined;
export function getCredentials(done?: CallbackHandler) {
void request
.post(api('login'))
.send({
user: adminUsername,
password: adminPassword,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
credentials['X-Auth-Token'] = res.body.data.authToken;
credentials['X-User-Id'] = res.body.data.userId;
instanceId = res.headers['x-instance-id'];
})
.end(done);
}
export function getInstanceId() {
return instanceId;
}

View File

@@ -0,0 +1,669 @@
# Test App Packages
Includes pre-built app packages that are designed to test specific APIs exposed by the Apps-engine.
DISCLAIMER: App related source code shown here are for testing purposes only, and don't reflect best practices or recommended implementation.
## How to use
In your tests, add a `before` step and call the `installLocalTestPackage` function, passing the path of your desired package. For instance:
```javascript
import { appImplementsIPreFileUpload } from '../../data/apps/app-packages';
import { installLocalTestPackage } from '../../data/apps/helper';
describe('My tests', () => {
before(async () => {
await installLocalTestPackage(appImplementsIPreFileUpload);
});
});
```
### Available apps
#### IPreFileUpload handler
File name: `file-upload-test_0.0.1.zip`
An app that handles the `IPreFileUpload` event. If the file name starts with `"test-should-reject"`, the app will prevent the upload from happening. The error message will contain the contents of the uploaded file as evidence that the app could successfully read them.
<details>
<summary>App source code</summary>
```typescript
import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors';
import { App } from '@rocket.chat/apps-engine/definition/App';
import { FileUploadNotAllowedException } from '@rocket.chat/apps-engine/definition/exceptions';
import { IPreFileUpload } from '@rocket.chat/apps-engine/definition/uploads';
import { IFileUploadContext } from '@rocket.chat/apps-engine/definition/uploads/IFileUploadContext';
export class TestIPreFileUpload extends App implements IPreFileUpload {
public async executePreFileUpload(
context: IFileUploadContext,
read: IRead,
http: IHttp,
persis: IPersistence,
modify: IModify,
): Promise<void> {
if (context.file.name.startsWith('test-should-reject')) {
console.log('[executePreFileUpload] Rejecting file which name starts with "test-should-reject"');
throw new FileUploadNotAllowedException(`Test file rejected ${context.content.toString()}`);
}
console.log('[executePreFileUpload] Did not reject file');
}
}
```
</details>
#### API Parameter Test
File name: `api-parameter-test_0.0.1.zip`
An app that provides a public API endpoint with URL parameters. The endpoint path is `/api/:param1/:param2/test` and returns the values of both parameters in the response.
**Response format:**
- Content-Type: `text/plain`
- Body: `Param1: <param1_value>, Param2: <param2_value>`
- Status: 200 OK
<details>
<summary>App source code</summary>
**APIParameterTestApp.ts**
```typescript
import { IAppAccessors, IConfigurationExtend, IEnvironmentRead, ILogger } from '@rocket.chat/apps-engine/definition/accessors';
import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api';
import { App } from '@rocket.chat/apps-engine/definition/App';
import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import { TestEndpoint } from './TestEndpoint';
export class APIParameterTestApp extends App {
constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {
super(info, logger, accessors);
}
protected async extendConfiguration(configuration: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise<void> {
await configuration.api.provideApi({
visibility: ApiVisibility.PUBLIC,
security: ApiSecurity.UNSECURE,
endpoints: [new TestEndpoint(this)],
});
}
}
```
**TestEndpoint.ts**
```typescript
import { HttpStatusCode, IModify, IRead } from '@rocket.chat/apps-engine/definition/accessors';
import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api';
export class TestEndpoint extends ApiEndpoint {
public path = 'api/:param1/:param2/test';
public async get(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify): Promise<IApiResponse> {
return {
content: `Param1: ${request.params.param1}, Param2: ${request.params.param2}`,
status: HttpStatusCode.OK,
headers: {
'Content-Type': 'text/plain',
},
};
}
}
```
</details>
#### External ID Test (resolveVisitor API)
File name: `external-id-test_0.0.1.zip`
An app that tests the `ILivechatCreator.resolveVisitor()` and `ILivechatUpdater.updateVisitorExternalId()` APIs for resolving and updating livechat visitors by external identifiers. This is used to test the WhatsApp BSUID (Business Scoped User ID) support and progressive visitor enrichment.
**Endpoints:**
1. `POST /api/apps/public/:appId/resolve-visitor` - Resolve visitor by externalId with phone/email fallback
2. `POST /api/apps/public/:appId/update-external-id` - Update visitor's externalId for this app
**Request body (resolve-visitor):**
```json
{
"externalId": { "entityId": "bsuid-123", "metadata": { "username": "@johndoe" } },
"phone": "+1234567890"
}
```
**Request body (update-external-id):**
```json
{
"visitorId": "visitor-123",
"externalId": { "entityId": "bsuid-123", "metadata": { "username": "@johndoe" } }
}
```
**Response:**
- Returns the visitor if found/updated
- Returns `{ visitor: null }` if not found
<details>
<summary>App source code</summary>
**ExternalIdTestApp.ts**
```typescript
import {
IAppAccessors,
IConfigurationExtend,
ILogger,
} from '@rocket.chat/apps-engine/definition/accessors';
import { App } from '@rocket.chat/apps-engine/definition/App';
import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api';
import { ResolveVisitorEndpoint } from './ResolveVisitorEndpoint';
import { UpdateExternalIdEndpoint } from './UpdateExternalIdEndpoint';
export class ExternalIdTestApp extends App {
constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {
super(info, logger, accessors);
}
public override async extendConfiguration(configuration: IConfigurationExtend): Promise<void> {
await configuration.api.provideApi({
visibility: ApiVisibility.PUBLIC,
security: ApiSecurity.UNSECURE,
endpoints: [new ResolveVisitorEndpoint(this), new UpdateExternalIdEndpoint(this)],
});
}
}
```
**ResolveVisitorEndpoint.ts**
```typescript
import {
HttpStatusCode,
IHttp,
IModify,
IPersistence,
IRead,
} from '@rocket.chat/apps-engine/definition/accessors';
import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api';
export class ResolveVisitorEndpoint extends ApiEndpoint {
public override path = 'resolve-visitor';
public async post(
request: IApiRequest,
_endpoint: IApiEndpointInfo,
_read: IRead,
modify: IModify,
_http: IHttp,
_persistence: IPersistence,
): Promise<IApiResponse> {
const { externalId, phone, email } = request.content;
let contactData: { phone: string } | { email: string } | undefined;
if (phone) {
contactData = { phone };
} else if (email) {
contactData = { email };
}
const visitor = await modify.getCreator().getLivechatCreator().resolveVisitor(externalId, contactData);
return {
status: HttpStatusCode.OK,
content: { visitor: visitor || null },
};
}
}
```
**UpdateExternalIdEndpoint.ts**
```typescript
import {
HttpStatusCode,
IHttp,
IModify,
IPersistence,
IRead,
} from '@rocket.chat/apps-engine/definition/accessors';
import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api';
export class UpdateExternalIdEndpoint extends ApiEndpoint {
public override path = 'update-external-id';
public async post(
request: IApiRequest,
_endpoint: IApiEndpointInfo,
_read: IRead,
modify: IModify,
_http: IHttp,
_persistence: IPersistence,
): Promise<IApiResponse> {
const { visitorId, externalId } = request.content;
if (!visitorId || !externalId) {
return {
status: HttpStatusCode.BAD_REQUEST,
content: { error: 'visitorId and externalId are required' },
};
}
const visitor = await modify.getUpdater().getLivechatUpdater().updateVisitorExternalId(visitorId, externalId);
return {
status: HttpStatusCode.OK,
content: { visitor: visitor || null },
};
}
}
```
</details>
#### Nested Requests simulation
File name: `nested-requests_0.0.1.zip`
An app that simulates a "nested request" scenario. It listens for `IPostMessageSent` events, and provides a slashcommand that sends a message. Executing the slashcommand will create a scenario where the event handler for `IPostMessageSent` will be triggered by the slashcommand executor handler.
This situation used to cause logs for the originating handler (the slashcommand executor, in this case) to disappear, and only the logs for the nested request (`IPostMessageSent` handler, in this case) would be persisted to the database.
<details>
<summary>App source code</summary>
```typescript
export class NestedRequestsApp extends App implements IPostMessageSent {
public async executePostMessageSent(
message: IMessage,
_read: IRead,
_http: IHttp,
_persistence: IPersistence,
_modify: IModify,
): Promise<void> {
this.getLogger().debug('executed_post_message_sent', message.id);
}
protected async extendConfiguration(configuration: IConfigurationExtend, _environmentRead: IEnvironmentRead): Promise<void> {
configuration.slashCommands.provideSlashCommand(
new (class implements ISlashCommand {
public command = 'nest';
public i18nParamsExample = 'nest';
public i18nDescription = 'nest';
public providesPreview = false;
constructor(private readonly app: IApp) {}
public async executor(
context: SlashCommandContext,
_read: IRead,
modify: IModify,
_http: IHttp,
_persis: IPersistence,
): Promise<void> {
const [execId] = context.getArguments();
this.app.getLogger().debug('slashcommand_triggered', execId);
const mb = modify.getCreator().startMessage().setText(`nested_test_message ${execId}`).setRoom(context.getRoom());
const id = await modify.getCreator().finish(mb);
this.app.getLogger().debug('slashcommand_message_sent', id);
}
})(this),
);
}
}
```
</details>
#### Update Status Test
File name: `update-status-test_0.0.1.zip`
An app that provides two public API endpoints to test the `updateStatus` and `updateStatusText` bridge methods. A `username` parameter is required to specify the target user.
**Endpoints:**
- `POST /update-status` — Calls `updateStatus(user, statusText, status)`. Expects `{ username: string, status: string, statusText?: string }`.
- `POST /update-status-text` — Calls `updateStatusText(user, statusText)`. Expects `{ username: string, statusText: string }`.
<details>
<summary>App source code</summary>
**UpdateStatusTestApp.ts**
```typescript
import {
IAppAccessors,
IConfigurationExtend,
ILogger,
} from '@rocket.chat/apps-engine/definition/accessors';
import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api';
import { App } from '@rocket.chat/apps-engine/definition/App';
import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import { UpdateStatusEndpoint } from './endpoints/UpdateStatusEndpoint';
import { UpdateStatusTextEndpoint } from './endpoints/UpdateStatusTextEndpoint';
export class UpdateStatusTestApp extends App {
constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {
super(info, logger, accessors);
}
protected async extendConfiguration(configuration: IConfigurationExtend): Promise<void> {
await configuration.api.provideApi({
visibility: ApiVisibility.PUBLIC,
security: ApiSecurity.UNSECURE,
endpoints: [
new UpdateStatusEndpoint(this),
new UpdateStatusTextEndpoint(this),
],
});
}
}
```
**endpoints/UpdateStatusEndpoint.ts**
```typescript
import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors';
import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api';
import { IUser } from '@rocket.chat/apps-engine/definition/users';
export class UpdateStatusEndpoint extends ApiEndpoint {
public path = 'update-status';
public async post(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise<IApiResponse> {
const { status, statusText = '', username } = request.content || {};
if (!status) {
return { status: 400, content: 'status is required' };
}
if (!username) {
return { status: 400, content: 'username is required' };
}
const user = await read.getUserReader().getByUsername(username) as IUser;
if (!user) {
return { status: 404, content: 'User not found' };
}
await modify.getUpdater().getUserUpdater().updateStatus(user, statusText, status);
return this.success(JSON.stringify({ status, statusText }));
}
}
```
**endpoints/UpdateStatusTextEndpoint.ts**
```typescript
import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors';
import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api';
import { IUser } from '@rocket.chat/apps-engine/definition/users';
export class UpdateStatusTextEndpoint extends ApiEndpoint {
public path = 'update-status-text';
public async post(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise<IApiResponse> {
const { statusText, username } = request.content || {};
if (typeof statusText !== 'string') {
return { status: 400, content: 'statusText is required' };
}
if (!username) {
return { status: 400, content: 'username is required' };
}
const user = await read.getUserReader().getByUsername(username) as IUser;
if (!user) {
return { status: 404, content: 'User not found' };
}
await modify.getUpdater().getUserUpdater().updateStatusText(user, statusText);
return this.success(JSON.stringify({ statusText }));
}
}
```
</details>
#### Message Reaction Test
File name: `message-updater-test_0.0.1.zip`
An app used to test the message reaction updates. Provides a `/msg-update` slashcommand that takes an action of `'add' | 'remove'` and a message id, then adds or removes a reaction in the target message accordingly using the App's bot user.
<details>
<summary>App source code</summary>
```typescript
export class MessageUpdaterTestApp extends App {
protected async extendConfiguration(configuration: IConfigurationExtend, _environmentRead: IEnvironmentRead): Promise<void> {
await configuration.slashCommands.provideSlashCommand(new class UpdateCommand implements ISlashCommand {
command = 'msg-update';
i18nDescription = 'msg-update';
i18nParamsExample = 'msg-update';
providesPreview = false;
constructor(private readonly app: App) { }
public async executor(context: SlashCommandContext, read: IRead, modify: IModify, _http: IHttp, _persis: IPersistence) {
const [action, msgId] = context.getArguments() as ['add' | 'remove', string];
const user = await read.getUserReader().getAppUser();
if (!user) {
this.app.getLogger().error(`Couldn't find app user`);
return;
}
if (action === 'add') {
await modify.getUpdater().getMessageUpdater().addReaction(msgId, user.id, ':+1:');
this.app.getLogger().debug(`Added reaction 👍 to message ${msgId}`);
} else {
await modify.getUpdater().getMessageUpdater().removeReaction(msgId, user.id, ':+1:');
this.app.getLogger().debug(`Removed reaction 👍 from message ${msgId}`);
}
}
}(this));
}
}
```
#### UIKit Room Context Test
File name: `uikit-room-test_0.0.1.zip`
An app that validates room context propagation and interaction data in UIKit interactions across all surfaces (message, modal, contextual bar). It provides a slash command (`open-uikit-room-test-modal`) with three actions:
- `modal` - Opens a modal with a button and submit action
- `ctx` - Opens a contextual bar with a button and submit action
- `message` - Sends a message with a button
It also takes a second argument with a random "seed" used to identify the current execution context.
Each interaction handler (`executeBlockActionHandler`, `executeViewSubmitHandler`, `executeViewClosedHandler`) logs detailed data including room ID, user name, view id, block value, triggerId, actionId, and container type. These logs are used by Playwright e2e tests to verify that interaction data is correctly propagated.
<details>
<summary>App source code</summary>
```typescript
import {
IAppAccessors,
IConfigurationExtend,
IEnvironmentRead,
IHttp,
ILogger,
IModify,
IPersistence,
IRead,
} from '@rocket.chat/apps-engine/definition/accessors';
import { App } from '@rocket.chat/apps-engine/definition/App';
import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import {
IUIKitInteractionHandler,
IUIKitResponse,
UIKitBlockInteractionContext,
UIKitViewCloseInteractionContext,
UIKitViewSubmitInteractionContext,
} from '@rocket.chat/apps-engine/definition/uikit';
import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata';
import { UIKitSurfaceType } from '@rocket.chat/apps-engine/definition/uikit';
export class UiKitRoomTestApp extends App implements IUIKitInteractionHandler {
constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {
super(info, logger, accessors);
}
async [AppMethod.UIKIT_BLOCK_ACTION](
context: UIKitBlockInteractionContext,
_read: IRead,
_http: IHttp,
_persistence: IPersistence,
_modify: IModify,
): Promise<IUIKitResponse> {
const data = context.getInteractionData();
this.getLogger().debug('block_action_room', data.room ? data.room.id : 'no-room');
this.getLogger().debug('block_action_user', data.user ? data.user.username : 'no-user');
this.getLogger().debug('block_action_triggerId', data.triggerId || 'no-triggerId');
this.getLogger().debug('block_action_actionId', data.actionId || 'no-actionId');
this.getLogger().debug('block_action_value', data.value);
this.getLogger().debug('block_action_container', data.container ? data.container.type : 'no-container');
return context.getInteractionResponder().successResponse();
}
async [AppMethod.UIKIT_VIEW_SUBMIT](
context: UIKitViewSubmitInteractionContext,
_read: IRead,
_http: IHttp,
_persistence: IPersistence,
_modify: IModify,
): Promise<IUIKitResponse> {
const data = context.getInteractionData();
this.getLogger().debug('view_submit_room', data.room ? data.room.id : 'no-room');
this.getLogger().debug('view_submit_user', data.user ? data.user.username : 'no-user');
this.getLogger().debug('view_submit_id', data.view.id);
this.getLogger().debug('view_submit_triggerId', data.triggerId || 'no-triggerId');
this.getLogger().debug('view_submit_actionId', data.actionId || 'no-actionId');
return context.getInteractionResponder().successResponse();
}
async [AppMethod.UIKIT_VIEW_CLOSE](
context: UIKitViewCloseInteractionContext,
_read: IRead,
_http: IHttp,
_persistence: IPersistence,
_modify: IModify,
): Promise<IUIKitResponse> {
const data = context.getInteractionData();
this.getLogger().debug('view_closed_room', data.room ? data.room.id : 'no-room');
this.getLogger().debug('view_closed_user', data.user ? data.user.username : 'no-user');
this.getLogger().debug('view_closed_id', data.view.id);
this.getLogger().debug('view_closed_triggerId', data.triggerId || 'no-triggerId');
this.getLogger().debug('view_closed_actionId', data.actionId || 'no-actionId');
return context.getInteractionResponder().successResponse();
}
protected async extendConfiguration(configuration: IConfigurationExtend, _environmentRead: IEnvironmentRead): Promise<void> {
await configuration.slashCommands.provideSlashCommand({
command: 'open-uikit-room-test-modal',
i18nDescription: '',
i18nParamsExample: '',
providesPreview: false,
executor: async (context, _read, modify, _http, _persis) => {
const [action, seed] = context.getArguments();
const triggerId = context.getTriggerId();
if (!triggerId) {
this.getLogger().error('No triggerId provided to slash command');
return;
}
if (action === 'modal') {
await modify.getUiController().openSurfaceView(
{
id: `modal-${seed}`,
title: { type: 'plain_text', text: 'UIKit Room Test Modal' },
type: UIKitSurfaceType.MODAL,
blocks: [
{
type: 'section',
text: { type: 'plain_text', text: 'This modal tests room context in UIKit interactions.' },
},
{
type: 'actions',
elements: [{ type: 'button', value: seed, actionId: 'modal-button', text: { type: 'plain_text', text: 'Click!' } }],
},
],
submit: {
type: 'button',
actionId: `modal-submit-${seed}`,
text: { type: 'plain_text', text: 'Submit' },
},
},
{ triggerId },
context.getSender(),
);
}
if (action === 'ctx') {
await modify.getUiController().openSurfaceView(
{
id: `ctx-${seed}`,
title: { type: 'plain_text', text: 'UIKit Room Test Contextual Bar' },
type: UIKitSurfaceType.CONTEXTUAL_BAR,
blocks: [
{
type: 'section',
text: { type: 'plain_text', text: 'This contextual bar tests room context.' },
},
{
type: 'actions',
elements: [{ type: 'button', value: seed, actionId: 'ctx-button', text: { type: 'plain_text', text: 'Click!' } }],
},
],
submit: {
type: 'button',
actionId: `ctxbar-submit-${seed}`,
text: { type: 'plain_text', text: 'Submit' },
},
},
{ triggerId },
context.getSender(),
);
}
if (action === 'message') {
const msg = modify
.getCreator()
.startMessage()
.setRoom(context.getRoom())
.setBlocks([
{
type: 'section',
text: { type: 'plain_text', text: 'This message tests room context.' },
},
{
type: 'actions',
elements: [{ type: 'button', value: seed, actionId: 'msg-button', text: { type: 'plain_text', text: 'Click!' } }],
},
]);
await modify.getCreator().finish(msg);
}
},
});
}
}
```
</details>

View File

@@ -0,0 +1,25 @@
import * as path from 'path';
export const appImplementsIPreFileUpload = path.resolve(__dirname, './file-upload-test_0.0.1.zip');
export const appAPIParameterTest = path.resolve(__dirname, './api-parameter-test_0.0.1.zip');
export const appCausingNestedRequests = path.resolve(__dirname, './nested-requests_0.0.1.zip');
export const appUiKitRoomTest = path.resolve(__dirname, './uikit-room-test_0.0.1.zip');
export const appUpdateStatusTest = path.resolve(__dirname, './update-status-test_0.0.1.zip');
export const appUpdateTest = path.resolve(__dirname, './app-update-test_0.0.1.zip');
// Same app id as appUpdateTest, but its `onEnable()` throws so the update applies yet the app ends up disabled.
export const appUpdateTestFaulty = path.resolve(__dirname, './app-update-test-faulty_0.0.2.zip');
// Same app id as appUpdateTest, but the package is missing its main class file, so the update fails to apply.
export const appUpdateTestBroken = path.resolve(__dirname, './app-update-test-broken_0.0.3.zip');
export const appExternalIdTest = path.resolve(__dirname, './external-id-test_0.0.1.zip');
export const messageReactionTest = path.resolve(__dirname, './message-updater-test_0.0.1.zip');
export const appPresenceStateTest = path.resolve(__dirname, './presence-state-test_0.0.1.zip');

View File

@@ -0,0 +1,16 @@
import type { Path } from '@rocket.chat/rest-typings';
export const APP_URL = 'https://github.com/RocketChat/Apps.RocketChat.Tester/raw/master/dist/appsrocketchattester_0.3.0.zip?raw=true';
export const APP_NAME = 'Apps.RocketChat.Tester';
type PathWithoutPrefix<TPath> = TPath extends `/apps${infer U}` ? U : never;
export function apps(path?: ''): `/api/apps`;
export function apps<TPath extends PathWithoutPrefix<Path>>(path: TPath): `/api/apps${TPath}`;
export function apps(path = '') {
return `/api/apps${path}` as const;
}
export function installedApps() {
return `/api/apps/installed` as const;
}

View File

@@ -0,0 +1,54 @@
import type { App } from '@rocket.chat/core-typings';
import { request, credentials } from '../api-data';
import { apps, APP_URL, installedApps } from './apps-data';
const getApps = () =>
new Promise<App[]>((resolve) => {
void request
.get(installedApps())
.set(credentials)
.end((_err, res) => {
resolve(res.body.apps);
});
});
const removeAppById = (id: App['id']) =>
new Promise((resolve) => {
void request
.delete(apps(`/${id}`))
.set(credentials)
.end(resolve);
});
export const cleanupApps = async () => {
const apps = await getApps();
await Promise.all(apps.map((testApp) => removeAppById(testApp.id)));
};
export const installTestApp = () =>
new Promise<App>((resolve) => {
void request
.post(apps())
.set(credentials)
.send({
url: APP_URL,
})
.end((_err, res) => {
resolve(res.body.app);
});
});
export const installLocalTestPackage = (path: string) =>
new Promise<App>((resolve, reject) => {
void request
.post(apps())
.set(credentials)
.attach('app', path)
.end((err, res) => {
if (err) {
return reject(err);
}
return resolve(res.body.app);
});
});

View File

@@ -0,0 +1,123 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IRoom, IMessage } from '@rocket.chat/core-typings';
import { api, credentials, request } from './api-data';
export const sendSimpleMessage = ({
roomId,
text = 'test message',
tmid,
userCredentials = credentials,
}: {
roomId: IRoom['_id'];
text?: string;
tmid?: IMessage['_id'];
userCredentials?: Credentials;
}) => {
if (!roomId) {
throw new Error('"roomId" is required in "sendSimpleMessage" test helper');
}
const message: {
rid: IRoom['_id'];
text: string;
tmid?: IMessage['_id'];
} = {
rid: roomId,
text,
};
if (tmid) {
message.tmid = tmid;
}
return request.post(api('chat.sendMessage')).set(userCredentials).send({ message });
};
export const sendMessage = ({
message,
requestCredentials,
}: {
message: { rid: IRoom['_id']; msg: string } & Partial<Omit<IMessage, 'rid' | 'msg'>>;
requestCredentials?: Credentials;
}) => {
return request
.post(api('chat.sendMessage'))
.set(requestCredentials ?? credentials)
.send({ message });
};
export const starMessage = ({ messageId, requestCredentials }: { messageId: IMessage['_id']; requestCredentials?: Credentials }) => {
return request
.post(api('chat.starMessage'))
.set(requestCredentials ?? credentials)
.send({ messageId });
};
export const pinMessage = ({
messageId,
requestCredentials,
unpin = false,
}: {
messageId: IMessage['_id'];
requestCredentials?: Credentials;
unpin?: boolean;
}) => {
return request
.post(api(unpin ? 'chat.unPinMessage' : 'chat.pinMessage'))
.set(requestCredentials ?? credentials)
.send({ messageId });
};
export const deleteMessage = ({ roomId, msgId }: { roomId: IRoom['_id']; msgId: IMessage['_id'] }) => {
if (!roomId) {
throw new Error('"roomId" is required in "deleteMessage" test helper');
}
if (!msgId) {
throw new Error('"msgId" is required in "deleteMessage" test helper');
}
return request.post(api('chat.delete')).set(credentials).send({
roomId,
msgId,
});
};
export const getMessageById = ({ msgId }: { msgId: IMessage['_id'] }) => {
if (!msgId) {
throw new Error('"msgId" is required in "getMessageById" test helper');
}
return new Promise<IMessage>((resolve) => {
void request
.get(api(`chat.getMessage`))
.query({ msgId })
.set(credentials)
.end((_err, res) => {
resolve(res.body.message);
});
});
};
export const followMessage = ({ msgId, requestCredentials }: { msgId: IMessage['_id']; requestCredentials?: Credentials }) => {
return request
.post(api('chat.followMessage'))
.set(requestCredentials ?? credentials)
.send({ mid: msgId });
};
export const updateMessage = ({
msgId,
requestCredentials,
updatedMessage,
roomId,
}: {
msgId: IMessage['_id'];
requestCredentials?: Credentials;
updatedMessage: string;
roomId?: IRoom['_id'];
}) => {
return request
.post(api('chat.update'))
.set(requestCredentials ?? credentials)
.send({ msgId, text: updatedMessage, roomId });
};

View File

@@ -0,0 +1 @@
export const CI_MAX_ROOMS_PER_GUEST = 10;

View File

@@ -0,0 +1,170 @@
import * as fs from 'fs';
import type { IMessage } from '@rocket.chat/core-typings';
import { api } from './api-data';
import type { IRequestConfig } from './users.helper';
/**
* Uploads a file to Rocket.Chat using the two-step process (rooms.media then rooms.mediaConfirm).
*
* @param roomId - The room ID where the file will be uploaded
* @param filePath - Path to the file to upload
* @param description - Description for the file
* @param config - Request configuration with credentials and request instance
* @param message - Optional message text to include with the file
* @returns Promise resolving to the message response
*/
export async function uploadFileToRC(
roomId: string,
filePath: string,
description: string,
config: IRequestConfig,
message = '',
): Promise<{ message: IMessage }> {
const requestInstance = config.request;
const credentialsInstance = config.credentials;
// Step 1: Upload file to rooms.media/:rid
const mediaResponse = await requestInstance
.post(api(`rooms.media/${roomId}`))
.set(credentialsInstance)
.attach('file', filePath)
.expect('Content-Type', 'application/json')
.expect(200);
if (!mediaResponse.body.success || !mediaResponse.body.file?._id) {
throw new Error(`File upload failed: ${JSON.stringify(mediaResponse.body)}`);
}
const fileId = mediaResponse.body.file._id;
// Step 2: Confirm and send message with rooms.mediaConfirm/:rid/:fileId
const confirmResponse = await requestInstance
.post(api(`rooms.mediaConfirm/${roomId}/${fileId}`))
.set(credentialsInstance)
.send({
msg: message,
description,
})
.expect('Content-Type', 'application/json')
.expect(200);
if (!confirmResponse.body.success || !confirmResponse.body.message) {
throw new Error(`File confirmation failed: ${JSON.stringify(confirmResponse.body)}`);
}
return confirmResponse.body;
}
/**
* Gets the list of files for a room.
*
* @param roomId - The room ID
* @param config - Request configuration
* @param options - Optional query parameters (name for filtering, count, offset)
* @returns Promise resolving to the files list response
*/
export async function getFilesList(
roomId: string,
config: IRequestConfig,
options: { name?: string; count?: number; offset?: number } = {},
): Promise<{
files: Array<{
_id: string;
name: string;
size: number;
type: string;
rid: string;
userId: string;
path?: string;
url?: string;
uploadedAt?: string;
federation?: {
mrid?: string;
mxcUri?: string;
serverName?: string;
mediaId?: string;
};
}>;
count: number;
offset: number;
total: number;
success: boolean;
}> {
const requestInstance = config.request;
const credentialsInstance = config.credentials;
const queryParams: Record<string, string> = {
roomId,
count: String(options.count || 10),
offset: String(options.offset || 0),
sort: JSON.stringify({ uploadedAt: -1 }),
};
if (options.name) {
queryParams.name = options.name;
}
const response = await requestInstance
.get(api('groups.files'))
.set(credentialsInstance)
.query(queryParams)
.expect('Content-Type', 'application/json')
.expect(200);
if (!response.body.success) {
throw new Error(`Failed to get files list: ${JSON.stringify(response.body)}`);
}
return response.body;
}
/**
* Downloads a file and verifies it matches the original file using binary comparison.
*
* @param fileUrl - The URL to download the file from (relative path like /file-upload/...)
* @param originalFilePath - Path to the original file to compare against
* @param config - Request configuration
* @returns Promise resolving to true if files match byte-by-byte
*/
export async function downloadFileAndVerifyBinary(fileUrl: string, originalFilePath: string, config: IRequestConfig): Promise<boolean> {
const requestInstance = config.request;
const credentialsInstance = config.credentials;
const response = await requestInstance.get(fileUrl).set(credentialsInstance).expect(200);
// Handle different response types:
// - For text/plain, supertest parses as JSON (resulting in {}), so use response.text
// - For binary files, response.body might be a Buffer
// - For other text types, response.text contains the content
let downloadedBuffer: Buffer;
if (Buffer.isBuffer(response.body)) {
// Binary file - response.body is already a Buffer
downloadedBuffer = response.body;
} else if (response.text !== undefined) {
// Text response (including text/plain) - use response.text to avoid JSON parsing
// Convert to Buffer using binary encoding to preserve exact bytes
downloadedBuffer = Buffer.from(response.text, 'binary');
} else if (typeof response.body === 'string') {
// Fallback: if body is a string, convert to buffer
downloadedBuffer = Buffer.from(response.body, 'binary');
} else {
// If body is an object (like {} from JSON parsing), this is an error
throw new Error(
`Failed to get file content. Response body type: ${typeof response.body}. ` +
`This usually means supertest parsed a text/plain response as JSON. ` +
`Response text available: ${response.text !== undefined ? 'yes' : 'no'}`,
);
}
// Read the original file
const originalBuffer = fs.readFileSync(originalFilePath);
// Compare buffers byte-by-byte
if (downloadedBuffer.length !== originalBuffer.length) {
return false;
}
return downloadedBuffer.equals(originalBuffer);
}

View File

@@ -0,0 +1,22 @@
import { api, credentials, request } from './api-data';
export const createGroup = ({ name }: { name: string }) => {
if (!name) {
throw new Error('"name" is required in "createGroup" test helper');
}
return request.post(api('groups.create')).set(credentials).send({ name });
};
export const deleteGroup = ({ groupId, roomName }: { groupId?: string; roomName?: string }) => {
if (!groupId && !roomName) {
throw new Error('"groupId" or "roomName" is required in "deleteGroup" test helper');
}
return request
.post(api('groups.delete'))
.set(credentials)
.send({
...(groupId && { groupId }),
...(roomName && { roomName }),
});
};

View File

@@ -0,0 +1,48 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IIntegration } from '@rocket.chat/core-typings';
import type { IntegrationsCreateProps } from '@rocket.chat/rest-typings';
import { api, credentials, request } from './api-data';
export const createIntegration = (integration: IntegrationsCreateProps, userCredentials: Credentials) =>
new Promise<IIntegration>((resolve, reject) => {
void request
.post(api('integrations.create'))
.set(userCredentials)
.send(integration)
.end((err, res) => {
if (err) {
reject(err);
return;
}
if (!res.body.success) {
reject(res.body);
return;
}
resolve(res.body.integration);
});
});
export const removeIntegration = (integrationId: IIntegration['_id'], type: 'incoming' | 'outgoing') =>
new Promise<void>((resolve) => {
void request
.post(api('integrations.remove'))
.set(credentials)
.send({
type: `webhook-${type}`,
integrationId,
})
.end((err, res) => {
if (err) {
console.warn(err);
}
if (!res.body.success) {
console.warn(res.body);
}
resolve();
});
});

View File

@@ -0,0 +1,3 @@
export const imgURL = './public/images/logo/1024x1024.png';
export const soundURL = './public/sounds/beep.mp3';
export const tiffURL = './tests/mocks/files/sample-tiff.tiff';

View File

@@ -0,0 +1,209 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import type { POSTLivechatBusinessHoursSaveParams } from '@rocket.chat/rest-typings';
import moment from 'moment';
import { api, credentials, request } from '../api-data';
import { updateEESetting, updateSetting } from '../permissions.helper';
type ISaveBhApiWorkHour = Omit<ILivechatBusinessHour, '_id' | 'ts' | 'timezone'> & {
workHours: { day: string; start: string; finish: string; open: boolean }[];
} & { departmentsToApplyBusinessHour?: string } & { timezoneName: string };
export const saveBusinessHour = async (businessHour: POSTLivechatBusinessHoursSaveParams) => {
const { body } = await request.post(api('livechat/business-hours.save')).set(credentials).send(businessHour);
return body;
};
export const createCustomBusinessHour = async (departments: string[], open = true): Promise<ILivechatBusinessHour> => {
const name = `business-hour-${Date.now()}`;
const businessHour: POSTLivechatBusinessHoursSaveParams = {
name,
active: true,
type: LivechatBusinessHourTypes.CUSTOM,
workHours: getWorkHours(open),
timezoneName: 'Asia/Calcutta',
timezone: 'Asia/Calcutta',
departmentsToApplyBusinessHour: '',
daysOpen: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
daysTime: [
{ day: 'Monday', start: { time: '08:00' }, finish: { time: '18:00' }, open },
{ day: 'Tuesday', start: { time: '08:00' }, finish: { time: '18:00' }, open },
{ day: 'Wednesday', start: { time: '08:00' }, finish: { time: '18:00' }, open },
{ day: 'Thursday', start: { time: '08:00' }, finish: { time: '18:00' }, open },
{ day: 'Friday', start: { time: '08:00' }, finish: { time: '18:00' }, open },
],
};
if (departments.length) {
businessHour.departmentsToApplyBusinessHour = departments.join(',');
}
await saveBusinessHour(businessHour);
const existingBusinessHours: ILivechatBusinessHour[] = await getAllCustomBusinessHours();
const createdBusinessHour = existingBusinessHours.find((bh) => bh.name === name);
if (!createdBusinessHour) {
throw new Error('Could not create business hour');
}
return createdBusinessHour;
};
export const makeDefaultBusinessHourActiveAndClosed = async () => {
// enable settings
await updateSetting('Livechat_enable_business_hours', true);
await updateEESetting('Livechat_business_hour_type', 'Single');
// create business hours
const {
body: { businessHour },
} = await request.get(api('livechat/business-hour')).query({ type: 'default' }).set(credentials).send();
const { workHours } = businessHour;
// Remove properties not accepted by the endpoint schema
const { _updatedAt, ts, ...cleanedBusinessHour } = businessHour;
const enabledBusinessHour = {
...cleanedBusinessHour,
timezoneName: 'America/Sao_Paulo',
timezone: 'America/Sao_Paulo',
departmentsToApplyBusinessHour: '',
daysOpen: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
daysTime: workHours.map((workHour: { open: boolean; start: { time: string }; finish: { time: string }; day: string }) => {
return {
open: true,
start: { time: '00:00' },
finish: { time: '00:01' },
day: workHour.day,
};
}),
workHours: workHours.map((workHour: { open: boolean; start: string; finish: string; day: string; code?: number }) => {
workHour.open = true;
workHour.start = '00:00';
workHour.finish = '00:01'; // if a job runs between 00:00 and 00:01, then this test will fail :P
delete workHour.code;
return workHour;
}),
};
return request.post(api('livechat/business-hours.save')).set(credentials).send(enabledBusinessHour).expect(200);
};
export const disableDefaultBusinessHour = async () => {
// disable settings
await updateSetting('Livechat_enable_business_hours', false);
await updateEESetting('Livechat_business_hour_type', 'Single');
// create business hours
const {
body: { businessHour },
} = await request.get(api('livechat/business-hour')).query({ type: 'default' }).set(credentials).send();
const { workHours } = businessHour;
// Remove properties not accepted by the endpoint schema
const { _updatedAt, ts, ...cleanedBusinessHour } = businessHour;
const disabledBusinessHour = {
...cleanedBusinessHour,
timezoneName: 'America/Sao_Paulo',
timezone: 'America/Sao_Paulo',
departmentsToApplyBusinessHour: '',
daysOpen: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
daysTime: workHours.map((workHour: { open: boolean; start: { time: string }; finish: { time: string }; day: string }) => {
return {
open: false,
start: { time: '00:00' },
finish: { time: '23:59' },
day: workHour.day,
};
}),
workHours: workHours.map((workHour: { open: boolean; start: string; finish: string; day: string }) => {
workHour.open = false;
workHour.start = '00:00';
workHour.finish = '23:59';
return workHour;
}),
};
return request.post(api('livechat/business-hours.save')).set(credentials).send(disabledBusinessHour).expect(200);
};
const removeCustomBusinessHour = async (businessHourId: string) => {
return request
.post(api('livechat/business-hours.remove'))
.set(credentials)
.send({ _id: businessHourId, type: LivechatBusinessHourTypes.CUSTOM })
.expect(200);
};
const getAllCustomBusinessHours = async (): Promise<ILivechatBusinessHour[]> => {
const response = await request.get(api('livechat/business-hours')).set(credentials).expect(200);
return (response.body.businessHours || []).filter(
(businessHour: ILivechatBusinessHour) => businessHour.type === LivechatBusinessHourTypes.CUSTOM,
);
};
export const removeAllCustomBusinessHours = async () => {
const existingBusinessHours: ILivechatBusinessHour[] = await getAllCustomBusinessHours();
const promises = existingBusinessHours.map((businessHour) => removeCustomBusinessHour(businessHour._id));
await Promise.all(promises);
};
export const getDefaultBusinessHour = async (): Promise<ILivechatBusinessHour> => {
const response = await request
.get(api('livechat/business-hour'))
.set(credentials)
.query({ type: LivechatBusinessHourTypes.DEFAULT })
.expect(200);
return response.body.businessHour;
};
export const getCustomBusinessHourById = async (businessHourId: string): Promise<ILivechatBusinessHour> => {
const response = await request
.get(api('livechat/business-hour'))
.set(credentials)
.query({ type: LivechatBusinessHourTypes.CUSTOM, _id: businessHourId })
.expect(200);
return response.body.businessHour;
};
// TODO: Refactor logic so object passed is of the correct type for POST /livechat/business-hours.save. See: CORE-1552
export const openOrCloseBusinessHour = async (businessHour: ILivechatBusinessHour, open: boolean) => {
const { _updatedAt, ts, ...cleanedBusinessHour } = businessHour;
const timezoneName = businessHour.timezone.name;
const enabledBusinessHour = {
...cleanedBusinessHour,
timezoneName,
timezone: timezoneName,
workHours: getWorkHours().map((workHour) => {
return {
...workHour,
open,
};
}),
departmentsToApplyBusinessHour: businessHour.departments?.map((department) => department._id).join(',') || '',
};
await saveBusinessHour(enabledBusinessHour as any);
};
export const getWorkHours = (open = true): ISaveBhApiWorkHour['workHours'] => {
const workHours: ISaveBhApiWorkHour['workHours'] = [];
for (let i = 0; i < 7; i++) {
workHours.push({
day: moment().day(i).format('dddd'),
start: '00:00',
finish: '23:59',
open,
});
}
return workHours;
};

View File

@@ -0,0 +1,25 @@
import { faker } from '@faker-js/faker';
import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { api, credentials, request } from '../api-data';
import type { DummyResponse } from './utils';
export const createCannedResponse = (): Promise<Partial<IOmnichannelCannedResponse>> =>
new Promise((resolve, reject) => {
const response = {
shortcut: `${faker.lorem.word()}-${Date.now()}`,
scope: 'user',
tags: [faker.string.uuid()],
text: faker.lorem.sentence(),
};
return request
.post(api(`canned-responses`))
.set(credentials)
.send(response)
.end((_err: Error, _res: DummyResponse<boolean>) => {
if (_err) {
return reject(_err);
}
resolve(response);
});
});

View File

@@ -0,0 +1,49 @@
import type { ILivechatCustomField } from '@rocket.chat/core-typings';
import type { Response } from 'supertest';
import { credentials, request, api } from '../api-data';
type ExtendedCustomField = Omit<ILivechatCustomField, '_id' | '_updatedAt'> & { field: string };
export const createCustomField = (customField: ExtendedCustomField): Promise<ILivechatCustomField> =>
new Promise((resolve, reject) => {
void request
.get(api(`livechat/custom-fields/${customField.label}`))
.set(credentials)
.send()
.end((err: Error, res: Response) => {
if (err) {
return reject(err);
}
if (res.body.customField !== null && res.body.customField !== undefined) {
resolve(res.body.customField);
} else {
void request
.post(api('livechat/custom-fields.save'))
.set(credentials)
.send({ customFieldId: null, customFieldData: customField })
.end((err: Error, res: Response): void => {
if (err) {
return reject(err);
}
resolve(res.body.customField);
});
}
});
});
export const deleteCustomField = (customFieldID: string) =>
new Promise((resolve, reject) => {
void request
.post(api('livechat/custom-fields.delete'))
.set(credentials)
.send({
customFieldId: customFieldID,
})
.end((err: Error, res: Response): void => {
if (err) {
return reject(err);
}
resolve(res.body);
});
});

View File

@@ -0,0 +1,225 @@
import { faker } from '@faker-js/faker';
import type { Credentials } from '@rocket.chat/api-client';
import type { ILivechatDepartment, IUser, LivechatDepartmentDTO } from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';
import { expect } from 'chai';
import { api, credentials, request } from '../api-data';
import { makeAgentAvailable } from './rooms';
import { createAgentAndReLogin, createAnAwayAgent, createAnOfflineAgent } from './users';
import type { WithRequiredProperty } from './utils';
const NewDepartmentData = ((): Partial<ILivechatDepartment> => ({
enabled: true,
name: `new department ${Random.id()}`,
description: 'created from api',
showOnRegistration: true,
email: faker.internet.email(),
showOnOfflineForm: true,
}))();
export const createDepartment = async (
departmentData?: Partial<ILivechatDepartment>,
agents?: { agentId: string; count?: string; order?: string }[],
): Promise<ILivechatDepartment> => {
const department = { ...NewDepartmentData, ...departmentData };
const response = await request
.post(api('livechat/department'))
.set(credentials)
.send({
department,
...(agents && { agents }),
})
.expect(200);
return response.body.department;
};
const updateDepartment = async (departmentId: string, departmentData: Partial<LivechatDepartmentDTO>): Promise<ILivechatDepartment> => {
const response = await request
.put(api(`livechat/department/${departmentId}`))
.set(credentials)
.send({
department: departmentData,
})
.expect(200);
return response.body.department;
};
export type OnlineAgent = {
user: WithRequiredProperty<IUser, 'username'>;
credentials: Credentials;
};
export const createDepartmentWithAnOnlineAgent = async (): Promise<{ department: ILivechatDepartment; agent: OnlineAgent }> => {
const { user, credentials: agentCredentials } = await createAgentAndReLogin();
const department = await createDepartment();
await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true);
await makeAgentAvailable(agentCredentials);
return {
department,
agent: {
credentials: agentCredentials,
user,
},
};
};
export const createDepartmentWith2OnlineAgents = async (): Promise<{
department: ILivechatDepartment;
agent1: OnlineAgent;
agent2: OnlineAgent;
}> => {
const [agent1, agent2] = await Promise.all([createAgentAndReLogin(), createAgentAndReLogin()]);
const department = await createDepartment(
{
enabled: true,
name: Random.id(),
showOnRegistration: true,
email: `${Random.id()}@example.com`,
showOnOfflineForm: true,
},
[{ agentId: agent1.user._id }, { agentId: agent2.user._id }],
);
await Promise.all([makeAgentAvailable(agent1.credentials), makeAgentAvailable(agent2.credentials)]);
return {
department,
agent1,
agent2,
};
};
export const createDepartmentWithAgent = async (agent: OnlineAgent): Promise<{ department: ILivechatDepartment; agent: OnlineAgent }> => {
const { user, credentials } = agent;
const department = await createDepartment();
await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true);
return {
department,
agent: {
credentials,
user,
},
};
};
export const addOrRemoveAgentFromDepartment = async (
departmentId: string,
agent: { agentId: string; username: string; count?: number; order?: number },
add: boolean,
) => {
const response = await request
.post(api(`livechat/department/${departmentId}/agents`))
.set(credentials)
.send({
...(add ? { upsert: [agent], remove: [] } : { remove: [agent], upsert: [] }),
});
if (response.status !== 200) {
throw new Error(`Failed to add or remove agent from department. Status code: ${response.status}\n${response.body}`);
}
};
export const createDepartmentWithAnOfflineAgent = async ({
allowReceiveForwardOffline = false,
fallbackForwardDepartment,
departmentsAllowedToForward,
}: {
allowReceiveForwardOffline?: boolean;
fallbackForwardDepartment?: string;
departmentsAllowedToForward?: string[];
}): Promise<{
department: ILivechatDepartment;
agent: {
credentials: Credentials;
user: WithRequiredProperty<IUser, 'username'>;
};
}> => {
const { user, credentials } = await createAnOfflineAgent();
const department = (await createDepartment({
allowReceiveForwardOffline,
fallbackForwardDepartment,
departmentsAllowedToForward,
})) as ILivechatDepartment;
await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true);
return {
department,
agent: {
credentials,
user,
},
};
};
export const createDepartmentWithAnAwayAgent = async ({
allowReceiveForwardOffline = false,
fallbackForwardDepartment,
departmentsAllowedToForward,
}: {
allowReceiveForwardOffline?: boolean;
fallbackForwardDepartment?: string;
departmentsAllowedToForward?: string[];
}): Promise<{
department: ILivechatDepartment;
agent: {
credentials: Credentials;
user: WithRequiredProperty<IUser, 'username'>;
};
}> => {
const { user, credentials } = await createAnAwayAgent();
const department = (await createDepartment({
allowReceiveForwardOffline,
fallbackForwardDepartment,
departmentsAllowedToForward,
})) as ILivechatDepartment;
await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true);
return {
department,
agent: {
credentials,
user,
},
};
};
export const archiveDepartment = async (departmentId: string): Promise<void> => {
await request
.post(api(`livechat/department/${departmentId}/archive`))
.set(credentials)
.expect(200);
};
export const disableDepartment = async (department: Omit<ILivechatDepartment, '_updatedAt'>): Promise<void> => {
department.enabled = false;
delete department._updatedAt;
const updatedDepartment = await updateDepartment(department._id, department);
expect(updatedDepartment.enabled).to.be.false;
};
export const deleteDepartment = async (departmentId: string): Promise<void> => {
await request
.delete(api(`livechat/department/${departmentId}`))
.set(credentials)
.expect(200);
};
export const getDepartmentById = async (departmentId: string): Promise<ILivechatDepartment> => {
const response = await request
.get(api(`livechat/department/${departmentId}`))
.set(credentials)
.expect(200);
return response.body.department;
};

View File

@@ -0,0 +1,31 @@
import { getCredentials, api, request, credentials } from '../api-data';
export const createEmailInbox = async (): Promise<{ _id: string }> => {
await new Promise((resolve) => getCredentials(resolve));
const { body } = await request
.post(api('email-inbox'))
.set(credentials)
.send({
name: 'test',
active: false,
email: `test${new Date().getTime()}@test.com`,
description: 'test',
senderInfo: 'test',
smtp: {
server: 'smtp.example.com',
port: 587,
username: 'xxxx',
password: 'xxxx',
secure: true,
},
imap: {
server: 'imap.example.com',
port: 993,
username: 'xxxx',
password: 'xxxx',
secure: true,
maxRetries: 10,
},
});
return body;
};

View File

@@ -0,0 +1,31 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { ILivechatInquiryRecord } from '@rocket.chat/core-typings';
import type { PaginatedResult } from '@rocket.chat/rest-typings';
import { api, request } from '../api-data';
export const fetchAllInquiries = async (credentials: Credentials, department?: string): Promise<ILivechatInquiryRecord[]> => {
const inquiries: ILivechatInquiryRecord[] = [];
let hasMore = true;
let offset = 0;
while (hasMore) {
const { body } = (await request
.get(api('livechat/inquiries.queuedForUser'))
.set(credentials)
.query({
...(department && { department }),
offset,
})
.expect('Content-Type', 'application/json')
.expect(200)) as { body: PaginatedResult<{ inquiries: Array<ILivechatInquiryRecord> }> };
inquiries.push(...body.inquiries);
hasMore = body.total > inquiries.length;
offset += body.count;
}
return inquiries;
};

View File

@@ -0,0 +1,71 @@
import type { ILivechatPriority, IOmnichannelServiceLevelAgreements } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { generateRandomSLAData } from '../../e2e/utils/omnichannel/sla';
import { api, credentials, request } from '../api-data';
import type { DummyResponse } from './utils';
export const createSLA = (): Promise<Omit<IOmnichannelServiceLevelAgreements, '_updated'>> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/sla'))
.set(credentials)
.send(generateRandomSLAData())
.end((err: Error, res: DummyResponse<{ sla: Omit<IOmnichannelServiceLevelAgreements, '_updated'> }, 'unwrapped'>) => {
if (err) {
return reject(err);
}
resolve(res.body.sla);
});
});
};
export const deleteSLA = (id: string): Promise<void> => {
return new Promise((resolve, reject) => {
void request
.delete(api(`livechat/sla/${id}`))
.set(credentials)
.send()
.end((err: Error, _res: DummyResponse<void, 'not-wrapped'>) => {
if (err) {
return reject(err);
}
resolve();
});
});
};
export const bulkCreateSLA = (amount: number): Promise<Omit<IOmnichannelServiceLevelAgreements, '_updated'>[]> => {
const promises = [];
for (let i = 0; i < amount; i++) {
promises.push(createSLA());
}
return Promise.all(promises);
};
export const deleteAllSLA = async (): Promise<void> => {
const response = await request.get(api('livechat/sla')).set(credentials).expect('Content-Type', 'application/json').expect(200);
expect(response.body).to.have.property('success', true);
expect(response.body.sla).to.be.an('array');
const {
body: { sla },
} = response as { body: { sla: IOmnichannelServiceLevelAgreements[] } };
const promises = sla.map((slaObj) =>
request
.delete(api(`livechat/sla/${slaObj._id}`))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200),
);
await Promise.all(promises);
};
export const getRandomPriority = async (): Promise<ILivechatPriority> => {
const response = await request.get(api('livechat/priorities')).set(credentials).expect('Content-Type', 'application/json').expect(200);
expect(response.body).to.have.property('success', true);
expect(response.body.priorities).to.be.an('array');
const {
body: { priorities },
} = response as { body: { priorities: ILivechatPriority[] } };
return priorities[Math.floor(Math.random() * priorities.length)];
};

View File

@@ -0,0 +1,494 @@
import { faker } from '@faker-js/faker';
import type { Credentials } from '@rocket.chat/api-client';
import type {
ILivechatInquiryRecord,
ILivechatAgent,
ILivechatDepartment,
ILivechatVisitor,
IMessage,
IOmnichannelRoom,
} from '@rocket.chat/core-typings';
import type { Response } from 'supertest';
import { api, credentials, methodCall, request } from '../api-data';
import { imgURL } from '../interactions';
import { getSettingValueById, restorePermissionToRoles, updateSetting } from '../permissions.helper';
import { adminUsername } from '../user';
import { getRandomVisitorToken } from './users';
import type { DummyResponse } from './utils';
import { sleep } from './utils';
export const createLivechatRoom = async (visitorToken: string, extraRoomParams?: Record<string, string>): Promise<IOmnichannelRoom> => {
const urlParams = new URLSearchParams();
urlParams.append('token', visitorToken);
if (extraRoomParams) {
for (const [key, value] of Object.entries(extraRoomParams)) {
urlParams.append(key, value);
}
}
const response = await request.get(api('livechat/room')).query(urlParams.toString()).set(credentials).expect(200);
return response.body.room;
};
export const createLivechatRoomWidget = async (
visitorToken: string,
extraRoomParams?: Record<string, string>,
): Promise<IOmnichannelRoom> => {
const urlParams = new URLSearchParams();
urlParams.append('token', visitorToken);
if (extraRoomParams) {
for (const [key, value] of Object.entries(extraRoomParams)) {
urlParams.append(key, value);
}
}
const response = await request
.get(api('livechat/room'))
.set('Cookie', [`rc_room_type=l`, `rc_is_widget=t`])
.query(urlParams.toString())
.set(credentials)
.expect(200);
return response.body.room;
};
export const createVisitorWithCustomData = async ({
department,
visitorName,
customPhone,
customFields,
customToken,
customEmail,
ignoreEmail = false,
ignorePhone = false,
}: {
department?: string;
customPhone?: string;
visitorName?: string;
customEmail?: string;
customFields?: { key: string; value: string; overwrite: boolean }[];
customToken?: string;
ignoreEmail?: boolean;
ignorePhone?: boolean;
}): Promise<ILivechatVisitor> => {
const token = customToken || getRandomVisitorToken();
const email = customEmail || `${token}@${token}.com`;
const phone = customPhone || `${Math.floor(Math.random() * 10000000000)}`;
try {
const res = await request.get(api(`livechat/visitor/${token}`));
if (res?.body?.visitor) {
return res.body.visitor as ILivechatVisitor;
}
} catch {
// Ignore errors from GET; we will create the visitor below.
}
const res = await request
.post(api('livechat/visitor'))
.set(credentials)
.send({
visitor: {
name: visitorName || `Visitor ${Date.now()}`,
token,
customFields: customFields || [{ key: 'address', value: 'Rocket.Chat street', overwrite: true }],
...(department ? { department } : {}),
...(!ignoreEmail ? { email } : {}),
...(!ignorePhone ? { phone } : {}),
},
});
return res.body.visitor as ILivechatVisitor;
};
export const createVisitor = (
department?: string,
visitorName?: string,
customEmail?: string,
customPhone?: string,
): Promise<ILivechatVisitor> =>
new Promise((resolve, reject) => {
const token = getRandomVisitorToken();
const email = customEmail || `${token}@${token}.com`;
const phone = customPhone || `${Math.floor(Math.random() * 10000000000)}`;
void request.get(api(`livechat/visitor/${token}`)).end((err: Error, res: DummyResponse<ILivechatVisitor>) => {
if (!err && res && res.body && res.body.visitor) {
return resolve(res.body.visitor);
}
void request
.post(api('livechat/visitor'))
.set(credentials)
.send({
visitor: {
name: visitorName || `Visitor ${Date.now()}`,
email,
token,
phone,
customFields: [{ key: 'address', value: 'Rocket.Chat street', overwrite: true }],
...(department ? { department } : {}),
},
})
.end((err: Error, res: DummyResponse<ILivechatVisitor>) => {
if (err) {
return reject(err);
}
resolve(res.body.visitor);
});
});
});
export const deleteVisitor = async (token: string): Promise<void> => {
await request.delete(api(`livechat/visitor/${token}`));
};
export const takeInquiry = async (inquiryId: string, agentCredentials?: Credentials): Promise<void> => {
const userId = agentCredentials ? agentCredentials['X-User-Id'] : credentials['X-User-Id'];
await request
.post(api('livechat/inquiries.take'))
.set(agentCredentials || credentials)
.send({ userId, inquiryId })
.expect(200);
};
export const fetchInquiry = (roomId: string): Promise<ILivechatInquiryRecord> => {
return new Promise((resolve, reject) => {
void request
.get(api('livechat/inquiries.getOne'))
.query({ roomId })
.set(credentials)
.end((err: Error, res: DummyResponse<ILivechatInquiryRecord>) => {
if (err) {
return reject(err);
}
resolve(res.body.inquiry);
});
});
};
export const createDepartment = (
agents?: { agentId: string; count?: number }[],
name?: string,
enabled = true,
opts: Record<string, any> = {},
departmentUnit?: { _id?: string },
userCredentials: Credentials = credentials,
): Promise<ILivechatDepartment> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/department'))
.set(userCredentials)
.send({
department: {
name: name || `Department ${Date.now()}`,
enabled,
showOnOfflineForm: true,
showOnRegistration: true,
email: 'a@b.com',
...opts,
},
agents,
departmentUnit,
})
.end((err: Error, res: DummyResponse<ILivechatDepartment>) => {
if (err) {
return reject(err);
}
resolve(res.body.department);
});
});
};
export const updateDepartment = ({
departmentId,
userCredentials,
agents,
name,
enabled = true,
opts = {},
departmentUnit,
}: {
departmentId: string;
userCredentials: Credentials;
agents?: { agentId: string }[];
name?: string;
enabled?: boolean;
opts?: Record<string, any>;
departmentUnit?: { _id?: string };
}): Promise<ILivechatDepartment> => {
return new Promise((resolve, reject) => {
void request
.put(api(`livechat/department/${departmentId}`))
.set(userCredentials)
.send({
department: {
name: name || `Department ${Date.now()}`,
enabled,
showOnOfflineForm: true,
showOnRegistration: true,
email: 'a@b.com',
...opts,
},
agents,
departmentUnit,
})
.end((err: Error, res: DummyResponse<ILivechatDepartment>) => {
if (err) {
return reject(err);
}
resolve(res.body.department);
});
});
};
export const deleteAgent = async (agentId: string = credentials['X-User-Id']): Promise<void> => {
await request
.delete(api(`livechat/users/agent/${agentId}`))
.set(credentials)
.expect(200);
};
export const createAgent = (overrideUsername?: string): Promise<ILivechatAgent> =>
new Promise((resolve, reject) => {
void request
.post(api('livechat/users/agent'))
.set(credentials)
.send({
username: overrideUsername || adminUsername,
})
.end((err: Error, res: DummyResponse<ILivechatAgent>) => {
if (err) {
return reject(err);
}
resolve(res.body.user);
});
});
export const createManager = (overrideUsername?: string): Promise<ILivechatAgent> =>
new Promise((resolve, reject) => {
void request
.post(api('livechat/users/manager'))
.set(credentials)
.send({
username: overrideUsername || adminUsername,
})
.end((err: Error, res: DummyResponse<ILivechatAgent>) => {
if (err) {
return reject(err);
}
resolve(res.body.user);
});
});
export const switchLivechatStatus = async (status: 'available' | 'not-available', overrideCredentials?: Credentials): Promise<void> => {
await request
.post(api('livechat/agent.status'))
.set(overrideCredentials || credentials)
.send({ status })
.expect(200);
};
export const makeAgentAvailable = async (overrideCredentials?: Credentials): Promise<Response> => {
await restorePermissionToRoles('view-l-room');
await request
.post(api('users.setStatus'))
.set(overrideCredentials || credentials)
.send({
message: '',
status: 'online',
});
return request
.post(api('livechat/agent.status'))
.set(overrideCredentials || credentials)
.send({
status: 'available',
});
};
export const makeAgentUnavailable = async (overrideCredentials?: Credentials): Promise<void> => {
await request
.post(api('users.setStatus'))
.set(overrideCredentials || credentials)
.send({ message: '', status: 'offline' })
.expect(200);
await request
.post(api('livechat/agent.status'))
.set(overrideCredentials || credentials)
.send({
status: 'not-available',
})
.expect(200);
};
export const getLivechatRoomInfo = (roomId: string): Promise<IOmnichannelRoom> => {
return new Promise((resolve /* , reject*/) => {
void request
.get(api('channels.info'))
.set(credentials)
.query({
roomId,
})
.end((_err: Error, res: DummyResponse<IOmnichannelRoom>) => {
resolve(res.body.channel);
});
});
};
/**
* @summary Sends message as visitor
*/
export const sendMessage = (roomId: string, message: string, visitorToken: string): Promise<IMessage> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/message'))
.set(credentials)
.send({
rid: roomId,
msg: message,
token: visitorToken,
})
.end((err: Error, res: DummyResponse<IMessage>) => {
if (err) {
return reject(err);
}
resolve(res.body.message);
});
});
};
export const uploadFile = (roomId: string, visitorToken: string): Promise<IMessage> => {
return new Promise((resolve, reject) => {
void request
.post(api(`livechat/upload/${roomId}`))
.set({ 'x-visitor-token': visitorToken, ...credentials })
.attach('file', imgURL)
.end((err: Error, res: DummyResponse<IMessage>) => {
if (err) {
return reject(err);
}
resolve(res.body as unknown as IMessage);
});
});
};
// Sends a message using sendMessage method from agent
export const sendAgentMessage = (roomId: string, msg?: string, userCredentials: Credentials = credentials): Promise<IMessage> => {
return new Promise((resolve, reject) => {
void request
.post(methodCall('sendMessage'))
.set(userCredentials)
.send({
message: JSON.stringify({
method: 'sendMessage',
params: [{ rid: roomId, msg: msg || faker.lorem.sentence() }],
id: 'id',
msg: 'method',
}),
})
.end((err: Error, res: any) => {
if (err) {
return reject(err);
}
resolve(JSON.parse(res.body.message).result);
});
});
};
export const fetchMessages = (roomId: string, visitorToken: string): Promise<IMessage[]> => {
return new Promise((resolve, reject) => {
void request
.get(api(`livechat/messages.history/${roomId}`))
.set(credentials)
.query({
token: visitorToken,
})
.end((err: Error, res: DummyResponse<IMessage[]>) => {
if (err) {
return reject(err);
}
if (!res.body.success) {
reject(res.body);
return;
}
resolve(res.body.messages);
});
});
};
export const closeOmnichannelRoom = async (roomId: string, tags?: string[]): Promise<void> => {
await request
.post(api('livechat/room.closeByUser'))
.set(credentials)
.send({ rid: roomId, ...(tags && { tags }), comment: faker.lorem.sentence() })
.expect(200);
};
export const bulkCreateLivechatRooms = async (
amount: number,
department?: string,
resolveRoomExtraParams?: (index: number) => Record<string, string> | undefined,
): Promise<IOmnichannelRoom[]> => {
const rooms: IOmnichannelRoom[] = [];
for (let i = 0; i < amount; i++) {
const visitor = await createVisitor(department);
const extraRoomParams = resolveRoomExtraParams ? resolveRoomExtraParams(i) : {};
const room = await createLivechatRoom(visitor.token, extraRoomParams);
rooms.push(room);
}
return rooms;
};
export const startANewLivechatRoomAndTakeIt = async ({
departmentId,
agent,
}: {
departmentId?: string;
agent?: Credentials;
} = {}): Promise<{ room: IOmnichannelRoom; visitor: ILivechatVisitor }> => {
const currentRoutingMethod = await getSettingValueById('Livechat_Routing_Method');
const routingMethodChanged = false;
if (currentRoutingMethod !== 'Manual_Selection') {
await updateSetting('Livechat_Routing_Method', 'Manual_Selection');
// wait for routing algorithm to stop
await sleep(1000);
}
const visitor = await createVisitor(departmentId);
const room = await createLivechatRoom(visitor.token);
const { _id: roomId } = room;
const inq = await fetchInquiry(roomId);
await takeInquiry(inq._id, agent);
await sendMessage(roomId, 'test message', visitor.token);
if (routingMethodChanged) {
await updateSetting('Livechat_Routing_Method', currentRoutingMethod);
// wait for routing algorithm to start
await sleep(1000);
}
return { room, visitor };
};
export const placeRoomOnHold = async (roomId: string): Promise<void> => {
await request.post(api('livechat/room.onHold')).set(credentials).send({ roomId }).expect(200);
};
export const moveBackToQueue = async (roomId: string, overrideCredentials?: Credentials): Promise<void> => {
await request
.post(api('livechat/inquiries.returnAsInquiry'))
.set(overrideCredentials || credentials)
.send({ roomId })
.expect('Content-Type', 'application/json')
.expect(200);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,50 @@
import { faker } from '@faker-js/faker';
import type { ILivechatTag, FindTagsResult } from '@rocket.chat/core-typings';
import { credentials, request, api } from '../api-data';
import type { DummyResponse } from './utils';
export const listTags = async (): Promise<FindTagsResult> => {
const { body } = await request.get(api('livechat/tags')).set(credentials).query({ viewAll: 'true' });
return body;
};
export const saveTags = async (departments: string[] = []): Promise<ILivechatTag> => {
const { body } = await request
.post(api('livechat/tags.save'))
.set(credentials)
.send({
tagData: {
name: faker.string.uuid(),
description: faker.lorem.sentence(),
},
...(departments.length > 0 && { tagDepartments: departments }),
});
return body;
};
export const removeAllTags = async (): Promise<boolean> => {
const tagsList = await listTags();
await Promise.all(tagsList.tags.map((tag) => removeTag(tag._id)));
const response = await request.get(api('livechat/tags')).set(credentials).expect('Content-Type', 'application/json').expect(200);
return response.body.tags.length === 0;
};
export const removeTag = (id: string): Promise<boolean> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/tags.delete'))
.set(credentials)
.send({ id })
.end((err: Error, res: DummyResponse<string, 'wrapped'>) => {
if (err) {
return reject(err);
}
resolve(JSON.parse(res.body.success).result);
});
});
};

View File

@@ -0,0 +1,55 @@
import { faker } from '@faker-js/faker';
import type { ILivechatTrigger } from '@rocket.chat/core-typings';
import { api, credentials, request } from '../api-data';
import type { DummyResponse } from './utils';
export const createTrigger = (name: string): Promise<boolean> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/triggers'))
.set(credentials)
.send({
name,
description: faker.lorem.sentence(),
enabled: true,
runOnce: faker.datatype.boolean(),
actions: [
{
name: 'send-message',
params: {
msg: faker.lorem.sentence(),
name: faker.person.firstName(),
sender: faker.helpers.arrayElement(['queue', 'custom']),
},
},
],
conditions: [
{
name: faker.helpers.arrayElement(['time-on-site', 'page-url', 'chat-opened-by-visitor', 'after-guest-registration']),
value: faker.string.alpha(),
},
],
})
.end((err: Error, _res: DummyResponse<boolean, 'unwrapped'>) => {
if (err) {
return reject(err);
}
resolve(true);
});
});
};
export const fetchTriggers = (): Promise<ILivechatTrigger[]> => {
return new Promise((resolve, reject) => {
void request
.get(api('livechat/triggers'))
.set(credentials)
.end((err: Error, res: DummyResponse<ILivechatTrigger[], 'wrapped'>) => {
if (err) {
return reject(err);
}
resolve(res.body.triggers);
});
});
};

View File

@@ -0,0 +1,85 @@
import { faker } from '@faker-js/faker';
import type { IOmnichannelBusinessUnit } from '@rocket.chat/core-typings';
import { methodCall, credentials, request, api } from '../api-data';
import type { DummyResponse } from './utils';
export const createMonitor = async (username: string): Promise<{ _id: string; username: string; role: string[] }> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/monitors.create'))
.set(credentials)
.send({ username })
.end((err: Error, res: DummyResponse<{ _id: string; username: string; role: string[] }, 'not-wrapped'>) => {
if (err) {
return reject(err);
}
resolve(res.body);
});
});
};
export const createUnit = async (
monitorId: string,
username: string,
departmentIds: string[],
name?: string,
extraMonitor: { monitorId: string; username: string }[] = [],
): Promise<IOmnichannelBusinessUnit> => {
return new Promise((resolve, reject) => {
void request
.post(api('livechat/units'))
.set(credentials)
.send({
unitData: {
name: name || `${faker.person.firstName()} ${faker.string.uuid()}`,
visibility: faker.helpers.arrayElement(['public', 'private']),
},
unitMonitors: [{ monitorId, username }, ...extraMonitor],
unitDepartments: departmentIds.map((departmentId) => ({ departmentId })),
})
.end((err: Error, res: DummyResponse<IOmnichannelBusinessUnit, 'not-wrapped'>) => {
if (err) {
return reject(err);
}
resolve(res.body);
});
});
};
export const deleteUnit = async (unit: IOmnichannelBusinessUnit): Promise<IOmnichannelBusinessUnit> => {
return new Promise((resolve, reject) => {
void request
.post(methodCall(`livechat:removeUnit`))
.set(credentials)
.send({
message: JSON.stringify({
method: 'livechat:removeUnit',
params: [unit._id],
id: '101',
msg: 'method',
}),
})
.end((err: Error, res: DummyResponse<string, 'wrapped'>) => {
if (err) {
return reject(err);
}
resolve(JSON.parse(res.body.message).result);
});
});
};
export const getUnit = (unitId: string): Promise<IOmnichannelBusinessUnit> => {
return new Promise((resolve, reject) => {
void request
.get(api(`livechat/units/${unitId}`))
.set(credentials)
.end((err: Error, res: DummyResponse<IOmnichannelBusinessUnit, 'not-wrapped'>) => {
if (err) {
return reject(err);
}
resolve(res.body);
});
});
};

View File

@@ -0,0 +1,128 @@
import { faker } from '@faker-js/faker';
import type { Credentials } from '@rocket.chat/api-client';
import { UserStatus, type ILivechatAgent, type IUser } from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';
import { api, credentials, request } from '../api-data';
import { password } from '../user';
import { createUser, login, setUserAway, setUserStatus } from '../users.helper';
import { createAgent, makeAgentAvailable, makeAgentUnavailable } from './rooms';
export const createBotAgent = async (): Promise<{
credentials: Credentials;
user: IUser;
}> => {
const agent = await createUser({
roles: ['bot'],
});
const createdUserCredentials = await login(agent.username, password);
await createAgent(agent.username);
await makeAgentAvailable(createdUserCredentials);
return {
credentials: createdUserCredentials,
user: agent,
};
};
export const getRandomVisitorToken = (): string => faker.string.alphanumeric(17);
export const getAgent = async (userId: string): Promise<ILivechatAgent> => {
const { body } = await request
.get(api(`livechat/users/agent/${userId}`))
.set(credentials)
.expect(200);
return body.user;
};
export const removeAgent = async (userId: string): Promise<void> => {
await request
.delete(api(`livechat/users/agent/${userId}`))
.set(credentials)
.expect(200);
};
export const createAgentAndReLogin = async (): Promise<{
credentials: Credentials;
user: IUser & { username: string };
}> => {
const username = `user.test.${Random.id()}`;
const email = `${username}@rocket.chat`;
const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password });
const agent = body.user;
const createdUserCredentials = await login(agent.username, password);
await createAgent(agent.username);
return {
credentials: createdUserCredentials,
user: agent,
};
};
export const createAnOnlineAgent = async (): Promise<{
credentials: Credentials;
user: IUser & { username: string };
}> => {
const { credentials: createdUserCredentials, user: agent } = await createAgentAndReLogin();
await makeAgentAvailable(createdUserCredentials);
return {
credentials: createdUserCredentials,
user: agent,
};
};
export const createAnOfflineAgent = async (): Promise<{
credentials: Credentials;
user: IUser & { username: string };
}> => {
const username = `user.test.${Date.now()}.offline`;
const email = `${username}.offline@rocket.chat`;
const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password });
const agent = body.user;
const createdUserCredentials = await login(agent.username, password);
await createAgent(agent.username);
await makeAgentUnavailable(createdUserCredentials);
return {
credentials: createdUserCredentials,
user: agent,
};
};
export const createAnAwayAgent = async (): Promise<{
credentials: Credentials;
user: IUser & { username: string };
}> => {
const username = `user.test.${Date.now()}.away`;
const email = `${username}.offline@rocket.chat`;
const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password });
const agent = body.user;
const createdUserCredentials = await login(agent.username, password);
await createAgent(agent.username);
await makeAgentAvailable(createdUserCredentials);
await setUserStatus(createdUserCredentials, UserStatus.AWAY);
await setUserAway(createdUserCredentials);
return {
credentials: createdUserCredentials,
user: agent,
};
};
export const updateLivechatSettingsForUser = async (
agentId: string,
livechatSettings: Record<string, any>,
agentDepartments: string[] = [],
): Promise<void> => {
await request
.post(api('livechat/agents.saveInfo'))
.set(credentials)
.send({
agentId,
agentData: livechatSettings,
agentDepartments,
})
.expect('Content-Type', 'application/json')
.expect(200);
};

View File

@@ -0,0 +1,17 @@
export type DummyResponse<T, E = 'wrapped'> = E extends 'wrapped' ? { body: { [k: string]: T } } : { body: T };
export type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
[Property in Key]-?: Type[Property];
};
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const parseMethodResponse = (response: any) => {
if (response.message) {
return JSON.parse(response.message);
}
return {};
};

View File

@@ -0,0 +1,13 @@
import type { ILivechatVisitor } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { api, credentials, request } from '../api-data';
export const getLivechatVisitorByToken = async (token: string): Promise<ILivechatVisitor> => {
const response = await request
.get(api(`livechat/visitor/${token}`))
.set(credentials)
.expect(200);
expect(response.body).to.have.property('visitor');
return response.body.visitor;
};

View File

@@ -0,0 +1,52 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { credentials, methodCall, request } from './api-data';
import type { IRequestConfig } from './users.helper';
type SendMessageParams = {
rid: IRoom['_id'];
msg: string;
config?: IRequestConfig;
};
/**
* Sends a text message to a room using the method.call/sendMessage DDP endpoint.
*
* This helper function allows sending messages to rooms (channels, groups, DMs)
* for federation testing scenarios using the DDP method format. It supports
* custom request configurations for cross-domain federation testing.
*
* @param rid - The unique identifier of the room
* @param msg - The message text to send
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the API response
*/
export const sendMessage = ({ rid, msg, config }: SendMessageParams) => {
if (!rid) {
throw new Error('"rid" is required in "sendMessage" test helper');
}
if (!msg) {
throw new Error('"msg" is required in "sendMessage" test helper');
}
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return requestInstance
.post(methodCall('sendMessage'))
.set(credentialsInstance)
.send({
message: JSON.stringify({
method: 'sendMessage',
params: [
{
_id: `${Date.now()}-${Math.random()}`,
rid,
msg,
},
],
id: `${Date.now()}-${Math.random()}`,
msg: 'method',
}),
});
};

View File

@@ -0,0 +1,93 @@
import { MOCK_SERVER_URL } from '../e2e/config/constants';
type Decision = 'DECISION_PERMIT' | 'DECISION_DENY';
export const mockServerSet = async (method: string, path: string, body: unknown, statusCode = 200, times = 0): Promise<void> => {
const res = await fetch(`${MOCK_SERVER_URL}/__mock/set`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method,
path,
response: { status_code: statusCode, body, times },
}),
});
if (!res.ok) {
throw new Error(`Failed to program mock-server: ${res.status} ${await res.text()}`);
}
};
export const mockServerSetMany = async (
mocks: Array<{ method: string; path: string; body: unknown; statusCode?: number; times?: number }>,
): Promise<void> => {
const res = await fetch(`${MOCK_SERVER_URL}/__mock/set-many`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
mocks.map((m) => ({
method: m.method,
path: m.path,
response: { status_code: m.statusCode ?? 200, body: m.body, times: m.times ?? 0 },
})),
),
});
if (!res.ok) {
throw new Error(`Failed to program mock-server (set-many): ${res.status} ${await res.text()}`);
}
};
export const mockServerReset = async (): Promise<void> => {
await fetch(`${MOCK_SERVER_URL}/__mock/reset`, { method: 'DELETE' });
};
export const mockServerHealthy = async (): Promise<boolean> => {
try {
const res = await fetch(`${MOCK_SERVER_URL}/__mock/health`);
return res.ok;
} catch {
return false;
}
};
export const seedDefaultMocks = async () => {
await mockServerSetMany([
{
method: 'GET',
path: '/healthz',
body: { status: 'SERVING' },
},
{
method: 'POST',
path: '/auth/realms/mock/protocol/openid-connect/token',
body: { access_token: 'mock-pdp-token', token_type: 'Bearer', expires_in: 3600 },
},
]);
};
export const seedGetDecisionBulk = async (
responses: Array<{ resourceDecisions: Array<{ decision: Decision; ephemeralResourceId?: string }> }>,
times = 0,
) => {
await mockServerSet('POST', '/authorization.v2.AuthorizationService/GetDecisionBulk', { decisionResponses: responses }, 200, times);
};
export const seedGetEntitlements = async (fqnMap: Record<string, unknown>, times = 0) => {
await mockServerSet(
'POST',
'/authorization.v2.AuthorizationService/GetEntitlements',
{ entitlements: [{ actionsPerAttributeValueFqn: fqnMap }] },
200,
times,
);
};
export const seedBulkDecisionByEntity = async (permitValues: string[], defaultDecision: Decision = 'DECISION_DENY') => {
const res = await fetch(`${MOCK_SERVER_URL}/__mock/set-bulk-decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permit_values: permitValues, default_decision: defaultDecision }),
});
if (!res.ok) {
throw new Error(`Failed to program mock-server (set-bulk-decision): ${res.status} ${await res.text()}`);
}
};

View File

@@ -0,0 +1,122 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { api, credentials, request } from './api-data';
import { omnichannelEEPermissions } from '../../ee/server/lib/omnichannel/permissions';
import { permissions } from '../../server/lib/authorization/constant/permissions';
import { IS_EE } from '../e2e/config/constants';
export const updatePermission = (permission: string, roles: string[]): Promise<void | Error> =>
new Promise((resolve, reject) => {
void request
.post(api('permissions.update'))
.set(credentials)
.send({ permissions: [{ _id: permission, roles }] })
.expect('Content-Type', 'application/json')
.expect(200)
.end((err?: Error) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
export const updateEEPermission = (permission: string, roles: string[]): Promise<void | Error> =>
IS_EE ? updatePermission(permission, roles) : Promise.resolve();
const updateManyPermissions = (permissions: { [key: string]: string[] }): Promise<void | Error> =>
new Promise((resolve, reject) => {
void request
.post(api('permissions.update'))
.set(credentials)
.send({ permissions: Object.keys(permissions).map((k) => ({ _id: k, roles: permissions[k] })) })
.expect('Content-Type', 'application/json')
.expect(200)
.end((err?: Error) => setTimeout(() => (!err && resolve()) || reject(err), 100));
});
export const updateSetting = (setting: string, value: ISetting['value'], debounce = true): Promise<void | Error> =>
new Promise((resolve, reject) => {
void request
.post(`/api/v1/settings/${setting}`)
.set(credentials)
.send({ value })
.expect('Content-Type', 'application/json')
.expect(200)
.end((err?: Error) => {
if (err) {
return reject(err);
}
if (debounce) {
setTimeout(resolve, 100);
return;
}
resolve();
});
});
export const getSettingValueById = async (setting: string): Promise<ISetting['value']> => {
const response = await request.get(`/api/v1/settings/${setting}`).set(credentials).expect('Content-Type', 'application/json').expect(200);
return response.body.value;
};
export const updateEESetting = (setting: string, value: ISetting['value']): Promise<void | Error> =>
IS_EE
? new Promise((resolve, reject) => {
void request
.post(`/api/v1/settings/${setting}`)
.set(credentials)
.send({ value })
.expect('Content-Type', 'application/json')
.expect(200)
.end((err?: Error) => setTimeout(() => (!err && resolve()) || reject(err), 100));
})
: Promise.resolve();
export const removePermissions = async (perms: string[]) => {
await updateManyPermissions(Object.fromEntries(perms.map((name) => [name, []])));
};
export const addPermissions = async (perms: { [key: string]: string[] }) => {
await updateManyPermissions(perms);
};
type Permission = (typeof permissions)[number]['_id'];
export const removePermissionFromAllRoles = async (permission: Permission) => {
await updatePermission(permission, []);
};
const getPermissions = () => {
if (!IS_EE) {
return permissions;
}
return [...permissions, ...omnichannelEEPermissions];
};
export const restorePermissionToRoles = async (permission: Permission) => {
const defaultPermission = getPermissions().find((p) => p._id === permission);
if (!defaultPermission) {
throw new Error(`No default roles found for permission ${permission}`);
}
const mutableDefaultRoles: string[] = defaultPermission.roles.map((r) => r);
if (!IS_EE) {
const eeOnlyRoles = ['livechat-monitor'];
eeOnlyRoles.forEach((role) => {
const index = mutableDefaultRoles.indexOf(role);
if (index !== -1) {
mutableDefaultRoles.splice(index, 1);
}
});
}
await updatePermission(permission, mutableDefaultRoles);
};

View File

@@ -0,0 +1,41 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IRole } from '@rocket.chat/core-typings';
import { api, credentials, request } from './api-data';
export const createCustomRole = async ({
name,
scope,
description,
credentials: customCredentials,
}: Pick<IRole, 'name' | 'scope' | 'description'> & { credentials?: Credentials }) => {
const response = await request
.post(api('roles.create'))
.set(customCredentials || credentials)
.send({ name, scope, description });
return response.body.role as IRole;
};
export const deleteCustomRole = async ({ roleId, credentials: customCredentials }: { roleId: IRole['_id']; credentials?: Credentials }) => {
const response = await request
.post(api('roles.delete'))
.set(customCredentials || credentials)
.send({ roleId });
return response.body.success as boolean;
};
export const assignRoleToUser = async ({
username,
roleId,
credentials: customCredentials,
}: {
username: string;
roleId: string;
credentials?: Credentials;
}) => {
const response = await request
.post(api('roles.addUserToRole'))
.set(customCredentials || credentials)
.send({ username, roleId });
return response.body.success as boolean;
};

View File

@@ -0,0 +1,539 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IRoom, ISubscription, IUser, IMessage } from '@rocket.chat/core-typings';
import type { Endpoints } from '@rocket.chat/rest-typings';
import { api, credentials, methodCall, request } from './api-data';
import type { IRequestConfig } from './users.helper';
type CreateRoomParams = {
name?: IRoom['name'];
type: IRoom['t'];
username?: string;
members?: string[];
credentials?: Credentials;
readOnly?: boolean;
extraData?: Record<string, any>;
config?: IRequestConfig;
};
export const createRoom = ({
name,
type,
username,
members,
credentials: customCredentials,
extraData,
readOnly,
config,
}: CreateRoomParams) => {
if (!type) {
throw new Error('"type" is required in "createRoom.ts" test helper');
}
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || customCredentials || credentials;
if (type === 'd' && !username) {
throw new Error('To be able to create DM Room, you must provide the username');
}
const endpoints = {
c: 'channels.create',
p: 'groups.create',
d: 'im.create',
} as const;
const params = type === 'd' ? { username } : { name };
const roomType = endpoints[type as keyof typeof endpoints];
return requestInstance
.post(api(roomType))
.set(credentialsInstance)
.send({
...params,
...(members && { members }),
...(readOnly && { readOnly }),
...(extraData && { extraData }),
});
};
type ActionType = 'delete' | 'close' | 'addOwner' | 'removeOwner';
export type ActionRoomParams = {
action: ActionType;
type: Exclude<IRoom['t'], 'l'>;
roomId: IRoom['_id'];
overrideCredentials?: Credentials;
extraData?: Record<string, any>;
};
export function actionRoom({ action, type, roomId, overrideCredentials = credentials, extraData = {} }: ActionRoomParams) {
if (!type) {
throw new Error(`"type" is required in "${action}Room" test helper`);
}
if (!roomId) {
throw new Error(`"roomId" is required in "${action}Room" test helper`);
}
const endpoints = {
c: 'channels',
p: 'groups',
d: 'im',
} as const;
const path = `${endpoints[type]}.${action}` as const;
if (path === 'im.addOwner' || path === 'im.removeOwner') throw new Error(`invalid path ("${path}")`);
return new Promise((resolve) => {
void request
.post(api(path))
.set(overrideCredentials)
.send({
roomId,
...extraData,
})
.end(resolve);
});
}
export const deleteRoom = ({ type, roomId }: { type: ActionRoomParams['type']; roomId: IRoom['_id'] }) =>
actionRoom({ action: 'delete', type, roomId, overrideCredentials: credentials });
export const getSubscriptionByRoomId = (roomId: IRoom['_id'], userCredentials = credentials, req = request): Promise<ISubscription> =>
new Promise((resolve, reject) => {
void req
.get(api('subscriptions.getOne'))
.set(userCredentials)
.query({ roomId })
.end((err, res) => {
if (err) {
return reject(err);
}
if (!res.body?.subscription) {
return reject(new Error('Subscription not found'));
}
resolve(res.body.subscription);
});
});
/**
* Adds users to a room using the REST invite endpoints (channels.invite / groups.invite).
*
* This is the entrypoint the "Add users" UI uses. Supports both local and federated users.
* The REST endpoints accept a single invitee per call, so this issues one request per
* username (mirroring how the UI fans out the invites) and resolves to the array of responses.
*
* @param usernames - Array of usernames to add to the room
* @param rid - The unique identifier of the room
* @param type - Room type, selects the endpoint: 'c' -> channels.invite, 'p' -> groups.invite
* @param userCredentials - Optional credentials for the request (deprecated, use config instead)
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the array of REST invite responses (one per username)
*/
export const addUserToRoom = ({
usernames,
rid,
type = 'c',
userCredentials,
config,
}: {
usernames: string[];
rid: IRoom['_id'];
type?: 'c' | 'p';
userCredentials?: Credentials;
config?: IRequestConfig;
}) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || userCredentials || credentials;
const endpoint = type === 'p' ? 'groups.invite' : 'channels.invite';
// The REST invite endpoints accept a single invitee per call, so we issue one
// request per username (mirroring how the UI fans out the invites).
return Promise.all(
usernames.map((username) => requestInstance.post(api(endpoint)).set(credentialsInstance).send({ roomId: rid, username })),
);
};
/**
* Adds users to a room using the deprecated `addUsersToRoom` DDP method.
*
* The method is deprecated in favour of the REST invite endpoints (see {@link addUserToRoom}),
* but it is still a supported entrypoint. Prefer the REST helper for general test setup — this
* helper exists to keep dedicated coverage of the DDP-method entrypoint (e.g. federation invites).
*
* @param usernames - Array of usernames to add to the room
* @param rid - The unique identifier of the room
* @param userCredentials - Optional credentials for the request (deprecated, use config instead)
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the method call response
*/
export const addUserToRoomViaMethod = ({
usernames,
rid,
userCredentials,
config,
}: {
usernames: string[];
rid: IRoom['_id'];
userCredentials?: Credentials;
config?: IRequestConfig;
}) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || userCredentials || credentials;
return requestInstance
.post(methodCall('addUsersToRoom'))
.set(credentialsInstance)
.send({
message: JSON.stringify({
method: 'addUsersToRoom',
params: [{ rid, users: usernames }],
id: 'id',
msg: 'method',
}),
});
};
/**
* Adds users to a room using the /invite slash command via method.call.
*
* Executes the /invite slash command using the DDP method call to add users to a room.
* This simulates the user experience of using slash commands in the UI.
* Supports both local and federated users, with proper error handling for federation restrictions.
*
* @param usernames - Array of usernames to add to the room
* @param rid - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the method call response
* @note The slash command expects parameters: { cmd: string, params: string, msg: IMessage, triggerId: string }
*/
// TODO(ddp-removal): swap /api/v1/method.call/slashCommand for
// POST /v1/commands.run. Same caveat as addUserToRoom: federation specs
// inspect the DDP-style `message` payload and need to be ported to the
// REST envelope first.
export const addUserToRoomSlashCommand = ({
usernames,
rid,
config,
}: {
usernames: string[];
rid: IRoom['_id'];
config?: IRequestConfig;
}) => {
if (!usernames || usernames.length === 0) {
throw new Error('"usernames" is required in "addUserToRoomSlashCommand" test helper');
}
if (!rid) {
throw new Error('"rid" is required in "addUserToRoomSlashCommand" test helper');
}
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return requestInstance
.post(methodCall('slashCommand'))
.set(credentialsInstance)
.send({
message: JSON.stringify({
method: 'slashCommand',
params: [
{
cmd: 'invite',
params: usernames.join(' '),
msg: {
rid,
_id: `test-${Date.now()}`,
},
triggerId: `test-trigger-${Date.now()}`,
},
],
id: 'id',
msg: 'method',
}),
});
};
/**
* Retrieves detailed information about a room.
*
* Fetches comprehensive room metadata including federation status,
* member counts, and other room properties needed for federation testing.
*
* @param roomId - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to room information response
*/
export const getRoomInfo = (roomId: IRoom['_id'], config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return new Promise<ReturnType<Endpoints['/v1/rooms.info']['GET']>>((resolve) => {
void requestInstance
.get(api('rooms.info'))
.set(credentialsInstance)
.query({
roomId,
})
.end((_err: any, req: any) => {
resolve(req.body);
});
});
};
/**
* Retrieves room members ordered by their role hierarchy.
*
* Gets the complete list of room members with their roles and permissions,
* ordered by importance. Essential for verifying federation member synchronization
* and role assignments across different Rocket.Chat instances.
*
* @param roomId - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to ordered member list response
*/
export const getRoomMembers = (roomId: IRoom['_id'], config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return new Promise<ReturnType<Endpoints['/v1/rooms.membersOrderedByRole']['GET']>>((resolve) => {
void requestInstance
.get(api('rooms.membersOrderedByRole'))
.set(credentialsInstance)
.query({
roomId,
})
.end((_err: any, req: any) => {
resolve(req.body);
});
});
};
/**
* Finds a specific room member with configurable retry logic.
*
* Searches for a member in a room by username, with retry logic to handle
* eventual consistency in federated systems. This is crucial for federation
* testing where member synchronization may take time to propagate.
*
* @param roomId - The unique identifier of the room to search
* @param username - The username to find
* @param options - Retry configuration options
* @param options.maxRetries - Maximum number of retry attempts (default: 3)
* @param options.delay - Delay between retries in milliseconds (default: 1000)
* @param options.initialDelay - Initial delay before first attempt in milliseconds (default: 0)
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the user object if found, null otherwise
*/
export const findRoomMember = async (
roomId: IRoom['_id'],
username: string,
options: { maxRetries?: number; delay?: number; initialDelay?: number } = {},
config?: IRequestConfig,
): Promise<IUser | null> => {
const { maxRetries = 3, delay = 1000, initialDelay = 0 } = options;
if (initialDelay > 0) {
await new Promise((resolve) => setTimeout(resolve, initialDelay));
}
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const membersResponse = await getRoomMembers(roomId, config);
const member = membersResponse.members.find((member: IUser) => member.username === username);
if (member) {
return member;
}
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
} catch (error) {
console.warn(`Attempt ${attempt} to find room member failed:`, error);
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
return null;
};
/**
* Retrieves the message history for a group/private room.
*
* Fetches the complete message history including system messages,
* user messages, and federation events. Essential for verifying
* message synchronization and system message generation in federated rooms.
*
* @param roomId - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to message history response
*/
export const getGroupHistory = (roomId: IRoom['_id'], config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return new Promise<ReturnType<Endpoints['/v1/groups.history']['GET']>>((resolve) => {
void requestInstance
.get(api('groups.history'))
.set(credentialsInstance)
.query({
roomId,
})
.end((_err: any, req: any) => {
resolve(req.body);
});
});
};
/**
* Loads message history for a room using the loadHistory method call.
*
* Fetches message history via the DDP method call endpoint, which returns
* messages with markdown parsing metadata (md attribute). This is useful
* for testing message rendering and markdown parsing, including emoji handling.
*
* @param rid - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @param end - Optional end date to load messages before this timestamp
* @param limit - Optional limit for number of messages to return (default: 20)
* @param ls - Optional last seen timestamp for unread calculation
* @param showThreadMessages - Optional flag to include thread messages (default: true)
* @returns Promise resolving to message history with structure: { messages, firstUnread?, unreadNotLoaded? }
*/
export const loadHistory = async (
rid: IRoom['_id'],
config?: IRequestConfig,
end?: Date,
limit?: number,
ls?: string | Date,
showThreadMessages?: boolean,
) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
const params: any[] = [rid];
if (end !== undefined) {
params.push(end);
}
if (limit !== undefined) {
params.push(limit);
}
if (ls !== undefined) {
params.push(ls);
}
if (showThreadMessages !== undefined) {
params.push(showThreadMessages);
}
const response = await requestInstance
.post(methodCall('loadHistory'))
.set(credentialsInstance)
.send({
message: JSON.stringify({
method: 'loadHistory',
params,
id: 'id',
msg: 'method',
}),
});
if (!response.body.success) {
throw new Error(`loadHistory failed: ${JSON.stringify(response.body)}`);
}
const data = JSON.parse(response.body.message);
if (data.error) {
throw new Error(`loadHistory method error: ${JSON.stringify(data.error)}`);
}
return data.result as {
messages: IMessage[];
firstUnread?: IMessage;
unreadNotLoaded?: number;
};
};
/**
* Accepts a room invite for the authenticated user.
*
* Processes a room invitation by accepting it, which grants the user
* access to the room. This is essential for federated room workflows
* where users receive invitations rather than auto-joining.
*
* @param roomId - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the acceptance response
*/
export const acceptRoomInvite = (roomId: IRoom['_id'], config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return new Promise<{ success: boolean; error?: string }>((resolve) => {
void requestInstance
.post(api('rooms.invite'))
.set(credentialsInstance)
.send({
roomId,
action: 'accept',
})
.end((_err: any, req: any) => {
resolve(req.body);
});
});
};
/**
* Retrieves the subscriptions for the authenticated user.
*
* Fetches the complete list of subscriptions for the authenticated user, which is essential
* for verifying federation subscription synchronization and member synchronization.
*
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the subscriptions response
*/
export const getSubscriptions = (config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return new Promise<ReturnType<Endpoints['/v1/subscriptions.get']['GET']>>((resolve) => {
void requestInstance
.get(api('subscriptions.get'))
.set(credentialsInstance)
.end((_err: any, req: any) => {
resolve(req.body);
});
});
};
/**
* Rejects a room invite for the authenticated user.
*
* Processes a room invitation by rejecting it, which prevents the user
* from joining the room and removes them from the invited members list.
* This is essential for federated room workflows where users can decline invitations.
*
* @param roomId - The unique identifier of the room
* @param config - Optional request configuration for custom domains
* @returns Promise resolving to the rejection response
*/
export const rejectRoomInvite = (roomId: IRoom['_id'], config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return new Promise<{ success: boolean; error?: string }>((resolve) => {
void requestInstance
.post(api('rooms.invite'))
.set(credentialsInstance)
.send({
roomId,
action: 'reject',
})
.end((_err: any, req: any) => {
resolve(req.body);
});
});
};

View File

@@ -0,0 +1,35 @@
import { credentials, methodCall, request } from './api-data';
/**
* Executes an app slash command via a POST request to the slashCommand method.
*
* @param cmd - The slashcommand name to execute
* @param rid - The room ID where the command will be executed
* @param params - Optional parameters to pass to the command (default: '')
* @param triggerId - Optional trigger ID for the command invocation (default: 'triggerId')
*
* @returns Promise resolving to the API response
*/
export const executeAppSlashCommand = (cmd: string, rid: string, params = '', triggerId = 'triggerId') =>
request
.post(methodCall('slashCommand'))
.set(credentials)
.send({
message: JSON.stringify({
id: 'id',
msg: 'method',
method: 'slashCommand',
params: [
{
cmd,
params,
triggerId,
msg: {
rid,
_id: 'message_id',
msg: `/${cmd} ${params}`.trim(),
},
},
],
}),
});

View File

@@ -0,0 +1,27 @@
import type { ITeam, TeamType } from '@rocket.chat/core-typings';
import { api, request } from './api-data';
export const createTeam = async (
credentials: Record<string, any>,
teamName: string,
type: TeamType,
members?: string[],
): Promise<ITeam> => {
const response = await request
.post(api('teams.create'))
.set(credentials)
.send({
name: teamName,
type,
...(members && { members }),
});
return response.body.team;
};
export const deleteTeam = async (credentials: Record<string, any>, teamName: string): Promise<void> => {
await request.post(api('teams.delete')).set(credentials).send({
teamName,
});
};

View File

@@ -0,0 +1,380 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { after, before, it } from 'mocha';
import type { Response } from 'supertest';
import { api, request, credentials } from './api-data';
import { imgURL, soundURL } from './interactions';
import { updateSetting } from './permissions.helper';
import { addUserToRoom, createRoom, deleteRoom } from './rooms.helper';
import { password } from './user';
import { createUser, deleteUser, login } from './users.helper';
export async function testFileUploads(
filesEndpoint: 'channels.files' | 'groups.files' | 'im.files',
roomType: 'c' | 'd' | 'p',
invalidRoomError = 'error-room-not-found',
) {
let testRoom: IRoom;
const propertyMap = {
c: 'channel',
p: 'group',
d: 'room',
};
let user: any;
before(async () => {
await Promise.all([updateSetting('Message_KeepHistory', true)]);
user = await createUser();
testRoom = (
await createRoom({
type: roomType,
...(roomType === 'd' ? { username: user.username } : { name: `channel-files-${Date.now()}` }),
} as any)
).body[propertyMap[roomType]];
});
after(() =>
Promise.all([deleteRoom({ type: 'c' as const, roomId: testRoom._id }), updateSetting('Message_KeepHistory', false), deleteUser(user)]),
);
it('should fail if invalid channel', (done) => {
void request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: 'invalid',
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res: Response) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', invalidRoomError);
})
.end(done);
});
it('should succeed when searching by roomId', (done) => {
void request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: testRoom._id,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array');
})
.end(done);
});
it('should succeed when searching by roomId even requested with count and offset params', (done) => {
void request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: testRoom._id,
count: 5,
offset: 0,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array');
})
.end(done);
});
it('should succeed when searching by roomName', function (done) {
if (!testRoom.name) {
this.skip();
}
void request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomName: testRoom.name,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array');
})
.end(done);
});
it('should succeed when searching by roomName even requested with count and offset params', function (done) {
if (!testRoom.name) {
this.skip();
}
void request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomName: testRoom.name,
count: 5,
offset: 0,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array');
})
.end(done);
});
let fileId: string;
it('should not return thumbnails', async () => {
await request
.post(api(`rooms.media/${testRoom._id}`))
.set(credentials)
.attach('file', imgURL)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
fileId = res.body.file._id;
});
await request
.post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId}`))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200);
await request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: testRoom._id,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array').with.lengthOf(1);
const { files } = res.body;
files.forEach((file: unknown) => {
expect(file).to.not.have.property('originalFileId');
});
});
});
it('should not return hidden files', async () => {
let msgId;
await request
.post(api(`rooms.media/${testRoom._id}`))
.set(credentials)
.attach('file', imgURL)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
fileId = res.body.file._id;
});
await request
.post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId}`))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
msgId = res.body.message._id;
});
await request
.post(api('chat.delete'))
.set(credentials)
.send({
roomId: testRoom._id,
msgId,
})
.expect('Content-Type', 'application/json')
.expect(200);
await request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: testRoom._id,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array').with.lengthOf(1);
const { files } = res.body;
files.forEach((file: unknown) => {
expect(file).to.have.property('_id').to.not.be.equal(fileId);
});
});
});
it('should properly filter files by name or typeGroup', async () => {
const fileOneName = 'image-zyxwv.png';
const fileTwoName = 'sound-abcde.png';
const fileIdsToConfirm: string[] = [];
// Post 2 files, one image and one audio
await Promise.all([
request
.post(api(`rooms.media/${testRoom._id}`))
.set(credentials)
.attach('file', imgURL, { filename: fileOneName })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(typeof res.body?.file?._id).to.equal('string');
fileIdsToConfirm.push(res.body.file._id);
}),
request
.post(api(`rooms.media/${testRoom._id}`))
.set(credentials)
.attach('file', soundURL, { filename: fileTwoName })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(typeof res.body?.file?._id).to.equal('string');
fileIdsToConfirm.push(res.body.file._id);
}),
]);
// Confirm the files
await Promise.all(
fileIdsToConfirm.map((fileId) =>
request
.post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId}`))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200),
),
);
// test filtering by name
const nameFilterTest = request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: testRoom._id,
name: fileOneName,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array').with.lengthOf(1);
const { files } = res.body;
expect(files[0].name).to.equal(fileOneName);
});
// test filtering by typeGroup
const typeGroupFilterTest = request
.get(api(filesEndpoint))
.set(credentials)
.query({
roomId: testRoom._id,
typeGroup: 'audio',
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('files').and.to.be.an('array').with.lengthOf(1);
const { files } = res.body;
expect(files[0].name).to.equal(fileTwoName);
});
await Promise.all([nameFilterTest, typeGroupFilterTest]);
});
describe('with another user', () => {
let anotherUserCreds: Credentials;
let anotherUser: IUser;
let extraRoom: IRoom;
before(async () => {
anotherUser = await createUser();
anotherUserCreds = await login(anotherUser.username, password);
extraRoom = (
await createRoom({
type: roomType,
...(roomType === 'd' ? { username: user.username } : { name: `channel-files-${Date.now()}` }),
credentials: anotherUserCreds,
} as any)
).body[propertyMap[roomType]];
if (roomType === 'p') {
await addUserToRoom({
rid: testRoom._id,
usernames: [anotherUser.username!],
type: 'p',
});
}
});
after(() => Promise.all([deleteUser(anotherUser), deleteRoom({ type: roomType, roomId: extraRoom._id })]));
it('should not allow to confirm a file from another user', async function () {
if (roomType === 'd') {
this.skip();
}
let fileId: string;
await request
.post(api(`rooms.media/${testRoom._id}`))
.set(anotherUserCreds)
.attach('file', imgURL)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
fileId = res.body.file._id;
});
await request
.post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId!}`))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(400);
});
it('should not allow to confirm a file that was not uploaded to the same room', async () => {
let fileId: string;
await request
.post(api(`rooms.media/${extraRoom._id}`))
.set(anotherUserCreds)
.attach('file', imgURL)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
fileId = res.body.file._id;
});
await request
.post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId!}`))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(400);
});
});
}

View File

@@ -0,0 +1,12 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IUser } from '@rocket.chat/core-typings';
export const password = 'R0ck3t.ch@tP@ssw0rd1234.!';
export const adminUsername = 'rocketchat.internal.admin.test';
export const adminEmail = `${adminUsername}@rocket.chat`;
export const adminPassword = adminUsername;
export type IUserWithCredentials = {
user: IUser;
credentials: Credentials;
};

View File

@@ -0,0 +1,201 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { IUser } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import supertest from 'supertest';
import type { Response } from 'supertest';
import { api, credentials, methodCall, request } from './api-data';
import { password } from './user';
export type TestUser<TUser extends IUser> = TUser & { username: string; emails: string[] };
/**
* Configuration interface for custom request handling.
*
* Provides a way to override the default request instance and credentials
* for testing scenarios that require custom domains or authentication.
*/
export interface IRequestConfig {
credentials: Credentials;
request: ReturnType<typeof supertest>;
}
/**
* Creates a request configuration for a specific domain.
*
* Sets up a new request instance and authenticates with the specified
* domain, user, and password. This is essential for federation testing
* where multiple Rocket.Chat instances need to be accessed.
*
* @param domain - The base URL of the Rocket.Chat instance
* @param user - The username for authentication
* @param password - The password for authentication
* @returns Promise resolving to request configuration with credentials
*/
export async function getRequestConfig(domain: string, user: string, password: string): Promise<IRequestConfig> {
const request = supertest(domain);
const credentials = await login(user, password, { request, credentials: {} as Credentials });
return {
credentials,
request,
};
}
export const createUser = <TUser extends IUser>(
userData: {
username?: string;
email?: string;
roles?: string[];
active?: boolean;
joinDefaultChannels?: boolean;
verified?: boolean;
requirePasswordChange?: boolean;
name?: string;
password?: string;
freeSwitchExtension?: string;
bio?: string;
nickname?: string;
} = {},
config?: IRequestConfig,
) =>
new Promise<TestUser<TUser>>((resolve, reject) => {
const username = userData.username || `user.test.${Date.now()}.${Math.random()}`;
const email = userData.email || `${username}@rocket.chat`;
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
void requestInstance
.post(api('users.create'))
.set(credentialsInstance)
.send({ email, name: username, username, password, ...userData })
.end((err: unknown, res: Response) => {
if (err) {
return reject(err);
}
resolve(res.body.user);
});
});
export const login = (username: string | undefined, password: string, config?: IRequestConfig): Promise<Credentials> =>
new Promise((resolve) => {
const requestInstance = config?.request || request;
void requestInstance
.post(api('login'))
.send({
user: username,
password,
})
.end((_err: unknown, res: Response) => {
resolve({
'X-Auth-Token': res.body.data.authToken,
'X-User-Id': res.body.data.userId,
});
});
});
export const deleteUser = async (user: Pick<IUser, '_id'>, extraData = {}, config?: IRequestConfig) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
return requestInstance
.post(api('users.delete'))
.set(credentialsInstance)
.send({
userId: user._id,
...extraData,
});
};
export const getUserByUsername = <TUser extends IUser>(username: string, config?: IRequestConfig) =>
new Promise<TestUser<TUser>>((resolve) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
void requestInstance
.get(api('users.info'))
.query({ username })
.set(credentialsInstance)
.end((_err: unknown, res: Response) => {
resolve(res.body.user);
});
});
export const getMe = <TUser extends IUser>(overrideCredential = credentials, config?: IRequestConfig) =>
new Promise<TestUser<TUser>>((resolve) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || overrideCredential;
void requestInstance
.get(api('me'))
.set(credentialsInstance)
.expect('Content-Type', 'application/json')
.expect(200)
.end((_end: unknown, res: Response) => {
resolve(res.body);
});
});
export const setUserActiveStatus = (userId: IUser['_id'], activeStatus = true, config?: IRequestConfig) =>
new Promise((resolve) => {
const requestInstance = config?.request || request;
const credentialsInstance = config?.credentials || credentials;
void requestInstance
.post(api('users.setActiveStatus'))
.set(credentialsInstance)
.send({
userId,
activeStatus,
})
.end(resolve);
});
export const setUserStatus = (overrideCredentials = credentials, status = UserStatus.ONLINE, config?: IRequestConfig) => {
const requestInstance = config?.request || request;
return requestInstance.post(api('users.setStatus')).set(overrideCredentials).send({
message: '',
status,
});
};
export const setUserAway = (overrideCredentials = credentials, config?: IRequestConfig) => {
const requestInstance = config?.request || request;
return requestInstance
.post(methodCall('UserPresence:away'))
.set(overrideCredentials)
.send({
message: JSON.stringify({
method: 'UserPresence:away',
params: [],
id: 'id',
msg: 'method',
}),
});
};
export const setUserOnline = (overrideCredentials = credentials, config?: IRequestConfig) => {
const requestInstance = config?.request || request;
return requestInstance
.post(methodCall('UserPresence:online'))
.set(overrideCredentials)
.send({
message: JSON.stringify({
method: 'UserPresence:online',
params: [],
id: 'id',
msg: 'method',
}),
});
};
export const removeRoleFromUser = (username: string, roleId: string, overrideCredentials = credentials, config?: IRequestConfig) =>
getUserByUsername(username, config).then((user) => {
const requestInstance = config?.request || request;
return requestInstance
.post(api('users.update'))
.set(overrideCredentials)
.send({
userId: user._id,
data: { roles: user.roles.filter((role) => role !== roleId) },
})
.expect(200);
});

View File

@@ -0,0 +1,40 @@
import type { Credentials } from '@rocket.chat/api-client';
import type { Path } from '@rocket.chat/rest-typings';
import { expect } from 'chai';
import { api, request, type PathWithoutPrefix } from './api-data';
export function withTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, ms);
return fn(controller.signal).finally(() => {
clearTimeout(timeoutId);
});
}
export const pagination = <TPath extends PathWithoutPrefix<Path>>(
apiEndpoint: TPath,
credentials: Credentials,
extraQueryParams: Record<string, any> = {},
) => {
return request
.get(api(apiEndpoint))
.set(credentials)
.query({
...extraQueryParams,
count: 10,
offset: 1,
sort: JSON.stringify({ _updatedAt: -1 }),
})
.expect('content-type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('count').that.is.a('number');
expect(res.body).to.have.property('offset').that.is.a('number');
expect(res.body).to.have.property('total').that.is.a('number');
});
};

View File

@@ -0,0 +1,11 @@
import { expect } from 'chai';
import type { request } from './api-data';
export function expectInvalidParams(res: Awaited<ReturnType<(typeof request)['post']>>, expectedErrors: string[]): void {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body.error).to.be.a('string');
expect(res.body.errorType).to.be.equal('invalid-params');
expect((res.body.error as string).split('\n').map((line) => line.trim())).to.be.deep.equal(expectedErrors);
}

View File

@@ -0,0 +1,410 @@
# E2E Testing with playwright
## Running tests
The application must be started with `TEST_MODE=true`
```sh
$ TEST_MODE=true yarn dev
```
Then we can run a single suite with
```sh
$ yarn test:e2e ./tests/e2e/administration.spec.ts
```
Or all the tests with
```sh
$ 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` Control the test execution
## Page Objects
- Any locator name must start with of one the following prefixes: `btn`, `link`, `input`, `select`, `checkbox`, `text`
## Important links
- [playwright docs](https://playwright.dev/docs/intro)
## Assertions and waiting
- Prefer Playwright web-first assertions such as `toBeVisible()` and `toHaveText()`. They retry until the expected condition is met.
- Use Playwright's `expect` matchers instead of Node-style `assert` statements.
- Rely on Playwright's built-in auto-waiting whenever possible.
- When an explicit wait is necessary, wait for a specific condition with APIs such as `locator.waitFor()`, `page.waitForURL()`, or `page.waitForResponse()`.
- Prefer waiting for observable conditions over fixed delays. If a fixed delay is unavoidable, document why no observable condition can be awaited.
```ts
await expect(anyElement).toBeVisible();
```
# Playwright Locator Best Practices
## Preferred locator types:
### 1. By role `.getByRole()`:
This is the most recommended locator type.
- It ensures our element is accessible to screen readers and other assistive technologies.
- It ensures that the element is uniquely identifiable.
- It's recommended to use the `exact` option to ensure that the locator doesn't match any other element with the same role
```ts
await page.getByRole('button', { name: 'Save', exact: true });
// Without the `exact` option, Playwright would match buttons with 'Save' and 'Save changes' labels.
```
### 2. By text `.getByText()` or `.getByLabel()`:
- Use this when locating by role is not possible or not sufficient.
- Be sure to restrict the scope of the locator to the element you are looking for.
### 3. Locator with `has`:
Our input elements hide the native input and render a custom component.
To target the input and trigger changes, you should locate the label that wraps the input and use the `has` locator.
```ts
page.locator('label', { has: this.page.getByRole('checkbox', { name: 'Private' }) });
```
## Locator types to avoid (at all costs):
If you are not able to find the element by role, label or text, it's a sign that the element is not accessible to the user.
The component should be refactored to allow a more accessible locator.
### 1. By `data-qa-id` or testId `.getByTestId()`:
```ts
// DON'T ❌
page.locator('[data-qa-id="menu-more-actions"]');
// DO ✅
page.getByRole('menu', {name: 'More actions', exact: true });
```
### 2. By elements and class names `.locator('div.class-name')`:
The HTML structure and/or class names can easily change over time, so it's better to use the locator by role and name.
```ts
// DON'T ❌
page.locator('#modal-root .rcx-button-group--align-end .rcx-button--primary');
// #modal-root was used to locate the injected portal element for the modal.
// This can be better located by role 'dialog' and making sure the modal has the proper name attribute.
// DO ✅
page.getByRole('dialog', name: 'Modal name example').getByRole('button', { name: 'Confirm', exact: true });
```
### 3. By position:
Using `nth-child` or similar selectors is not recommended as it can easily change.
It's a fragile locator and it's hard to maintain.
### 4. Parent/Child relationships:
A change in the DOM structure can break the test.
## Use our page-objects:
> apps/meteor/tests/e2e/page-objects
- page-objects are a great way to reuse locators across tests using `getters` and `methods`.
- They make it easier to write tests that are more readable and maintainable.
- Always make sure to use the most restricted scope possible - not the whole page to avoid multiple matches.
- Page objects own navigation - see [Navigation](#navigation-goto-owns-the-wait).
If you are writing a new test, make sure to look at the existing page-objects to see if there is a suitable one for your use case.
If not, evaluate if creating a reusable getter/method is worth it and create the ones you need in the respective context.
E.g.:
Writing a new test for a sidebar behavior.
- check `apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts` if the fragments you need are already there.
- If not, create new getters and/or methods in this file.
Usage example:
```ts
// apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts
// ...
get sidebar(): Locator {
return this.page.getByRole('navigation', { name: 'sidebar' });
}
get channelsList(): Locator {
return this.sidebar.getByRole('list', { name: 'Channels' });
}
// Restricted scope: inside navigation > sidebar > list named Channels > link with name
getSearchRoomByName(name: string) {
return this.channelsList.getByRole('link', { name });
}
```
```ts
// test.spec.ts
test('should display sidebar items', async ({ page }) => {
poHomeChannel = new HomeChannel(page);
await poHomeChannel.goto();
const targetChannel = 'channel-test';
await expect(poHomeChannel.sidebar.getSearchRoomByName(targetChannel)).toBeVisible();
});
```
## Navigation: `goto()` owns the wait
`page.goto()` resolves as soon as the document loads, long before the SPA has rendered - the next
locator then races the app. So page objects own navigation: `goto()` navigates *and* waits.
```ts
poAdminUsers = new AdminUsers(page);
await poAdminUsers.goto(); // the next line can act on the page
```
Never call `page.goto()` from a test. If the page you need has no page object, add one - under
`Admin`, `Account` or `OmnichannelAdmin` it is usually two lines:
```ts
export class AdminThirdPartyLogin extends Admin {
protected readonly route = AdminSectionsHref.thirdPartyLogin;
protected readonly title = 'Third-party login';
}
```
`title` is the page's heading, matched with `exact: true` - copy it from
`packages/i18n/src/locales/en.i18n.json` rather than guessing the casing. It also scopes
`pageContent`, the `main` region to hang the page's other locators off.
Anything else extends `RoutedPage` directly and implements `waitForReady()`. Override it when the
heading is not enough - `AdminUsers` waits for its search box, `OmnichannelAdmin` for its table.
Two more hooks:
- `gotoExpecting(until)` - the route settles on something else, e.g. an upsell dialog on CE.
- `navigateTo(route, until?)` - build extra `gotoSomething()` methods when a page object owns more
than one route (`gotoGeneral()`, `gotoChannel(name)`). `until` takes a `Locator` or an async waiter.
Auth screens (`Login`, `Registration`, `Authenticated`) take the url to navigate to, since an
anonymous visitor asking for `/home` lands somewhere else.
A raw `page.goto()` is still right for external urls, urls derived from `page.url()`, and tests that
must not wait for the route to settle.
## Cleanup after tests
- Remember to delete all users, channels, rooms, etc, created during the tests.
- Reset settings to their default values after the tests, if changed.
- Close all new pages opened during the tests.
- You can use the `test.afterAll()` or `test.afterEach()` methods to clean up after the tests.
```ts
// test.spec.ts
test.describe.serial('feature example', ({ api}) => {
let targetChannel;
let targetDiscussion;
test.beforeAll(async ({ api }) => {
// change setting value
await setSettingValueById(api, 'Accounts_AllowFeaturePreview', true);
// create channel
targetChannel = await createTargetChannel(api, { members: ['user1'] });
// create discussion
targetDiscussion = await createTargetDiscussion(api);
});
test.afterAll(async ({ api }) => {
// reset setting value
await setSettingValueById(api, 'Accounts_AllowFeaturePreview', false);
// delete channel
await deleteChannel(api, targetChannel);
// delete discussion
await deleteRoom(api, targetDiscussion._id);
});
})
```
## General recommendations
### Use descriptive test names
Test names should clearly describe the behavior and expected outcome being verified.
### Use `test.describe` for grouping tests
It can be used to group related tests into a test suite. It provides a way to organize tests logically and improve the readability and maintainability of your test code.
```ts
test.describe('Feature Test', () => {
test('should display the feature heading after navigation', async ({ api}) => {
// do some tests
});
});
```
### Use `test.step` for grouping steps
Enhances test readability and provides more detailed information in test reports
```ts
test.describe('Feature Test', () => {
test('should display the feature heading after navigation', async ({ page }) => {
await test.step('open the feature page', async () => {
const poFeature = new FeaturePage(page);
await poFeature.goto();
await expect(page.getByRole('heading', { name: 'Feature' })).toBeVisible();
});
});
});
```
### Big test files should not be `.serial`:
- Ok for tests with very few steps, when steps have dependencies on each other, you can simulate the user flow sequentially.
- Avoid chaining big tests with `test.serial` - dependant steps make it harder to debug and/or make small changes
> If you are changing something on the 34th step, you would have to run the whole test suite multiple times during development, instead of olny running the test step in question.
## Performance patterns
E2E tests are the most expensive tests we run. Two patterns, applied together, typically cut a suite's runtime by ~80% and remove the most common sources of flake. They are the standard for new suites and the target for migrating existing ones.
### Pattern 1 — seed state through the API, not the UI
Use the browser **only for what the test actually verifies**. Everything else — creating rooms, DMs, discussions, threads, sending setup messages — goes through REST.
Why:
- A REST call costs 30–150ms; the equivalent UI flow (menu → modal → input → submit → wait for nav) costs 2–8s per step.
- API errors are deterministic and synchronous. UI errors manifest as timeouts on overloaded CI, which is a slow and ambiguous signal.
- The test fails only when the feature under test breaks, not when an unrelated setup UI regresses. A broken "create discussion" modal should not take down every test that needs a discussion as fixture.
- Removes the most flake-prone interactions: hover-revealed menus, modal animations, search debounces.
Do **not** apply when the setup UI is the actual subject of the test (`create-direct.spec.ts`, `create-discussion.spec.ts`, `create-channel.spec.ts`, etc.). There, keep one test per creation flow going through the UI; unrelated tests rely on the API path.
### Pattern 2 — share the browser context across a serial suite
For suites already using `test.describe.serial`, move `browser.newContext(), pageObject.goto()` and the initial room navigation from `beforeEach` to `beforeAll`.
Why: bootstrapping a Playwright context plus hydrating the Meteor app costs ~1.5–2.5s per test. In a serial suite the isolation cost is already paid — collecting the speed benefit is free.
Preconditions:
- Suite is `test.describe.serial`.
- All tests share the same user / storage state.
- Each test leaves the page in a known "home position" (base room open, no modals, threads, or side panels lingering).
Do **not** apply when:
- Tests need different users or storage states.
- Suite covers auth, 2FA, or session lifecycle — the browser state is the subject.
- Suite has more than ~15 tests: the debug cost of shared state outgrows the speed win (see "Big test files should not be `.serial`" above).
### Pattern 3 — navigate to a known room by URL, not by search
When a test already knows the room it needs (a channel created via API, `general`, etc.), open it directly with `poHomeChannel.gotoChannel(name)` instead of going home and searching for it.
```ts
// ❌ fragile — three independent things must align before the combobox `fill` resolves
await page.goto('/home');
await poHomeChannel.navbar.openChat(targetChannel);
// ✅ deterministic — navigate straight to the room, then wait for it to be ready
await poHomeChannel.gotoChannel(targetChannel);
```
Why: searching the navbar couples the navigation to (1) the app shell hydrating, (2) the **search index** having picked up the room — a real lag for a channel created moments earlier via API — and (3) the listbox rendering and the option becoming clickable. Any one stalling makes `searchInput.fill` hang until the test timeout, surfacing as the misleading `Target page, context or browser has been closed`. `gotoChannel` loads `/channel/<name>` and then `waitForChannel()` (main region, heading, message list, `aria-busy` cleared), so it has a proper readiness wait without the search dependency.
Do **not** apply when:
- The navbar search is the actual subject of the test.
- Navigating to a **DM** (`/direct/<username>`) or a **discussion** — `gotoChannel` builds a `/channel/` URL only.
- Reaching a channel the user is **not a member of** — the direct URL hits a different preview/join flow; verify the behavior before switching.
## API helpers for state seeding
Prefer these helpers in `beforeAll` / `beforeEach` and in setup `test.step`s. All live under `apps/meteor/tests/e2e/utils/`.
| Intent | Helper | REST endpoint |
| ------------------------------------ | --------------------------------------------------------- | ------------------------- |
| Create public channel | `createTargetChannel(api)` | `/channels.create` |
| Create public channel (full room) | `createTargetChannelAndReturnFullRoom(api)` | `/channels.create` |
| Create private channel | `createTargetPrivateChannel(api)` | `/groups.create` |
| Create private group (full room) | `createTargetGroupAndReturnFullRoom(api)` | `/groups.create` |
| Create team | `createTargetTeam(api)` | `/teams.create` |
| Create discussion (fresh parent) | `createTargetDiscussion(api)` | `/rooms.createDiscussion` |
| Create discussion on existing msg | `createDiscussion(api, parentRoomId, parentMsgId, name)` | `/rooms.createDiscussion` |
| Create DM room (get id back) | `createDirectMessageRoom(api, username)` | `/im.create` |
| Send message to a room | `sendMessage(api, roomId, msg)` | `/chat.sendMessage` |
| Send message inside a thread | `sendMessage(api, roomId, msg, parentMsgId)` | `/chat.sendMessage` |
| Send message as a specific user | `sendMessageFromUser(request, user, rid, msg)` | `/chat.postMessage` |
| Delete channel (by name) | `deleteChannel(api, roomName)` | `/channels.delete` |
| Delete room (by id) | `deleteRoom(api, roomId)` | `/rooms.delete` |
| Delete team | `deleteTeam(api, teamName)` | `/teams.delete` |
If the helper you need is missing, add it under `utils/` and re-export it from `utils/index.ts` rather than inlining the REST call in the spec.
## Template: optimized `.serial` suite
Copy-paste starting point for a new suite that applies both patterns.
```ts
import type { Page, BrowserContext } from 'playwright-core';
import { Users } from './fixtures/userStates';
import { HomeChannel } from './page-objects';
import { createTargetChannel, sendMessage } from './utils';
import { expect, test } from './utils/test';
test.use({ storageState: Users.admin.state });
test.describe.serial('Feature X', () => {
let poHomeChannel: HomeChannel;
let targetChannel: string;
let targetChannelId: string;
let page: Page;
let context: BrowserContext;
test.beforeAll(async ({ browser, api }) => {
targetChannel = await createTargetChannel(api);
const info = await (await api.get(`/channels.info?roomName=${targetChannel}`)).json();
targetChannelId = info.channel._id;
context = await browser.newContext();
page = await context.newPage();
poHomeChannel = new HomeChannel(page);
await poHomeChannel.gotoChannel(targetChannel);
});
test.afterAll(async ({ api }) => {
await api.post('/channels.delete', { roomName: targetChannel });
await page.close();
await context.close();
});
test('exercises the feature', async ({ api }) => {
const messageText = 'seed';
await sendMessage(api, targetChannelId, messageText);
await expect(poHomeChannel.content.lastUserMessage).toContainText(messageText);
// assertions for the feature under test
});
});
```
## Migrating an existing suite
Recipe for a single spec. Keep PRs to at most 5 files so reviews stay tractable.
1. **Baseline**. Run the spec three times locally and record the median per-test time. Paste it in the PR body.
2. **Map setup**. For every test, mark each `test.step` and every `beforeEach` call that only prepares state (creates rooms, messages, threads, opens menus). These are candidates for API seeding.
3. **Apply Pattern 1**. Replace the marked UI setup with helpers from the table above. If a helper is missing, add it under `utils/` and export it from `utils/index.ts`.
4. **Apply Pattern 2** — only if the suite is `.serial` and the preconditions hold. Move context, page, navigation to `beforeAll`. Close page and context in `afterAll`. Ensure every test returns the page to the home position.
5. **Check coverage when consolidating**. If you merge tests (e.g. four formatting tests into one), confirm the merged test still asserts every behavior the originals covered. List the merges in the PR body.
6. **Compare**. Run three times again, record the new median. If the improvement is under 30%, revisit — the coupling may not be worth it.
7. **PR body must include**: before/after timings, the list of UI setups moved to API, and an explicit reason if you chose not to apply one of the patterns.
## Anti-patterns to flag in review
- `poHomeChannel.content.sendMessage(...)` used inside `beforeEach` or `beforeAll` — that is setup, should be `sendMessage(api, ...)`.
- Opening meatball menus or modals purely to create a discussion, thread, or DM as a setup step.
- `test.describe.serial` that builds a fresh context and navigates in `beforeEach` — both should be shared in `beforeAll`.
- Any raw `page.goto(...)` in a spec — use the page object's `goto()` (see [Navigation](#navigation-goto-owns-the-wait)).
- `goto()` + `navbar.openChat(name)` to reach a room the test already knows — use `poHomeChannel.gotoChannel(name)` (see Pattern 3). Searching a just-created channel races the search index and hangs until timeout.
- New non-serial suites whose tests still each carry >3s of UI setup.
- Inline `api.post('/im.create', …)` / `api.post('/chat.sendMessage', …)` in a spec instead of extending the helpers in `utils/`.

View File

@@ -0,0 +1,120 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { MongoClient } from 'mongodb';
import { IS_EE, URL_MONGODB } from './config/constants';
import { Users } from './fixtures/userStates';
import { HomeChannel } from './page-objects';
import { createTargetGroupAndReturnFullRoom, deleteRoom, setSettingValueById } from './utils';
import { convertHexToRGB } from './utils/convertHexToRGB';
import { expect, test } from './utils/test';
test.use({ storageState: Users.admin.state });
const attrKey = `clearance_${Date.now()}`;
const bannersConfig = {
version: 1,
enabled: true,
banner: {
style: 'classic',
uppercase: true,
monospace: false,
delimiter: ' // ',
colorMode: 'highest',
fallbackText: 'NO CLASSIFICATION DATA',
fallbackColor: '#6C727A',
},
attributes: [
{
id: 'clearance',
source: attrKey,
label: 'Clearance',
showInBanner: true,
showLabel: true,
bannerLabel: 'CLEARANCE',
labelSeparator: '-',
valueSeparator: '/',
sortAlpha: false,
groupThreshold: 0,
multipleLabel: 'MULTIPLE',
drivesColor: true,
values: [
{ source: 'TS', label: 'TOP SECRET', color: '#ff8c00' },
{ source: 'U', label: 'UNCLASSIFIED', color: '#007a33' },
],
},
],
};
test.describe.serial('abac-classification-banner', () => {
let poHomeChannel: HomeChannel;
let connection: MongoClient;
let room: IRoom;
let attributeId: string;
test.skip(!IS_EE, 'Enterprise Only');
test.beforeAll(async ({ api }) => {
connection = await MongoClient.connect(URL_MONGODB);
await Promise.all([
setSettingValueById(api, 'ABAC_Enabled', true),
setSettingValueById(api, 'ABAC_PDP_Type', 'local'),
setSettingValueById(api, 'ABAC_Attribute_Store', 'local'),
setSettingValueById(api, 'ABAC_Classification_Banners_Enabled', true),
setSettingValueById(api, 'ABAC_Classification_Banners_Config', JSON.stringify(bannersConfig)),
]);
expect((await api.post('/abac/attributes', { key: attrKey, values: ['TS', 'U'] })).status()).toBe(200);
const { attributes } = await (await api.get('/abac/attributes', { key: attrKey })).json();
attributeId = attributes.find((attribute: { key: string }) => attribute.key === attrKey)._id;
// the local PDP only keeps members whose own attributes match the room's,
// so the admin gets them straight in the database before the room is tagged
await connection
.db()
.collection<IUser>('users')
.updateOne({ username: Users.admin.data.username }, { $push: { abacAttributes: { key: attrKey, values: ['TS', 'U'] } } });
({ group: room } = await createTargetGroupAndReturnFullRoom(api));
expect((await api.put(`/abac/rooms/${room._id}/attributes/${attrKey}`, { values: ['TS'] })).status()).toBe(200);
});
test.afterAll(async ({ api }) => {
await deleteRoom(api, room._id);
await api.delete(`/abac/attributes/${attributeId}`);
await connection
.db()
.collection<IUser>('users')
.updateOne({ username: Users.admin.data.username }, { $pull: { abacAttributes: { key: attrKey } } });
await connection.close();
await Promise.all([
setSettingValueById(api, 'ABAC_Classification_Banners_Config', ''),
setSettingValueById(api, 'ABAC_Classification_Banners_Enabled', false),
setSettingValueById(api, 'ABAC_Enabled', false),
]);
});
test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
await poHomeChannel.goto();
});
test('should show the classification banner built from the config in an ABAC room', async ({ page }) => {
await poHomeChannel.navbar.openChat(room.name as string);
const banner = page.getByRole('region', { name: 'Room Attributes' });
await expect(banner).toBeVisible();
await expect(banner).toHaveText('CLEARANCE-TOP SECRET');
await expect(banner).toHaveCSS('background-color', convertHexToRGB('#ff8c00'));
});
test('should not show the banner when classification banners are disabled', async ({ page, api }) => {
await setSettingValueById(api, 'ABAC_Classification_Banners_Enabled', false);
await poHomeChannel.navbar.openChat(room.name as string);
await expect(poHomeChannel.composer.inputMessage).toBeVisible();
await expect(page.getByRole('region', { name: 'Room Attributes' })).toHaveCount(0);
});
});

View File

@@ -0,0 +1,110 @@
import { DEFAULT_USER_CREDENTIALS } from './config/constants';
import { AccountSecurity, HomeChannel, Login } from './page-objects';
import { test, expect } from './utils/test';
import { createTestUser, type ITestUser } from './utils/user-helpers';
test.describe.serial('Forget session on window close setting', () => {
let poLogin: Login;
test.beforeEach(async ({ page }) => {
poLogin = new Login(page);
await poLogin.goto();
});
test.describe('Setting off', async () => {
test.beforeAll(async ({ api }) => {
await api.post('/settings/Accounts_ForgetUserSessionOnWindowClose', { value: false });
});
test('Login using credentials and reload to stay logged in', async ({ page, context }) => {
await poLogin.login('user1', DEFAULT_USER_CREDENTIALS.password);
await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible();
const newPage = await context.newPage();
await new HomeChannel(newPage).goto();
await expect(newPage.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible();
});
});
test.describe('Setting on', async () => {
test.beforeAll(async ({ api }) => {
await api.post('/settings/Accounts_ForgetUserSessionOnWindowClose', { value: true });
});
test.afterAll(async ({ api }) => {
await api.post('/settings/Accounts_ForgetUserSessionOnWindowClose', { value: false });
});
test('Login using credentials and reload to stay logged in', async ({ page }) => {
await poLogin.login('user1', DEFAULT_USER_CREDENTIALS.password);
await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible();
await page.reload();
await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible();
});
test('Login using credentials in a new tab after first tab logged in', async ({ page, context }) => {
await poLogin.login('user1', DEFAULT_USER_CREDENTIALS.password);
await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible();
const newPage = await context.newPage();
const newPoLogin = new Login(newPage);
await newPoLogin.goto();
await newPoLogin.login('user1', DEFAULT_USER_CREDENTIALS.password);
await expect(newPage.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible();
});
test.describe('E2EE save password flow', () => {
// Dedicated throwaway user: resetE2EEPassword() calls Users.unsetLoginTokens(),
// which would invalidate the shared admin login token the `api` fixture
// authenticates with — breaking every subsequent admin-authenticated request
// in the worker (e.g. the next spec's beforeAll settings changes silently 401).
let e2eeUser: ITestUser;
test.beforeAll(async ({ api }) => {
await api.post('/settings/E2E_Enable', { value: true });
await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false });
e2eeUser = await createTestUser(api);
});
test.afterAll(async ({ api }) => {
await e2eeUser?.delete();
await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true });
await api.post('/settings/E2E_Enable', { value: false });
});
test('E2EE save password button opens modal in SAVE_PASSWORD state', async ({ page }) => {
const poHomeChannel = new HomeChannel(page);
const poAccountSecurity = new AccountSecurity(page);
await poLogin.login(e2eeUser.data.username, DEFAULT_USER_CREDENTIALS.password);
await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible();
await poAccountSecurity.goto();
await poAccountSecurity.resetE2EEPassword();
await page.locator('role=button[name="Login"]').waitFor();
await poLogin.login(e2eeUser.data.username, DEFAULT_USER_CREDENTIALS.password);
await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible();
await expect(poHomeChannel.bannerSaveEncryptionPassword).toBeVisible();
await poHomeChannel.bannerSaveEncryptionPassword.click();
const randomPassword = await page.evaluate(() => window.sessionStorage.getItem('e2e.randomPassword') ?? '');
expect(randomPassword).toBeTruthy();
await expect(poHomeChannel.dialogSaveE2EEPassword).toBeVisible();
await expect(poHomeChannel.dialogSaveE2EEPassword).toContainText(randomPassword);
});
});
});
});

View File

@@ -0,0 +1,44 @@
import { Users, restoreState } from './fixtures/userStates';
import { test, expect } from './utils/test';
test.use({ storageState: Users.userE2EE.state });
test.describe('Account Login', () => {
test.beforeAll(async ({ api }) => {
await api.post('/settings/E2E_Enable', { value: true });
await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true });
});
test.afterAll(async ({ api }) => {
await api.post('/settings/E2E_Enable', { value: false });
await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false });
});
test.beforeEach(async ({ page }) => {
await page.goto('/home');
await restoreState(page, Users.userE2EE);
});
test('should remove private_key and public_key from localStorage when token expires', async ({ page }) => {
expect(await page.evaluate(() => localStorage.getItem('Meteor.userId'))).not.toBeNull();
expect(await page.evaluate(() => localStorage.getItem('Meteor.loginToken'))).not.toBeNull();
expect(await page.evaluate(() => localStorage.getItem('Meteor.loginTokenExpires'))).not.toBeNull();
expect(await page.evaluate(() => localStorage.getItem('private_key'))).not.toBeNull();
// public key acquisition takes time and now happens only after login cycle is complete
await page.waitForFunction(() => localStorage.getItem('public_key'));
await page.evaluate(() => {
localStorage.setItem('Meteor.loginTokenExpires', new Date().toString());
});
await page.reload();
await expect(page.getByRole('form', { name: 'Login' })).toBeVisible();
expect(await page.evaluate(() => localStorage.getItem('Meteor.userId'))).toBeNull();
expect(await page.evaluate(() => localStorage.getItem('Meteor.loginToken'))).toBeNull();
expect(await page.evaluate(() => localStorage.getItem('Meteor.loginTokenExpires'))).toBeNull();
expect(await page.evaluate(() => localStorage.getItem('private_key'))).toBeNull();
expect(await page.evaluate(() => localStorage.getItem('public_key'))).toBeNull();
});
});

View File

@@ -0,0 +1,66 @@
import type { Page } from 'playwright-core';
import { IS_EE } from './config/constants';
import { createAuxContext } from './fixtures/createAuxContext';
import injectInitialData from './fixtures/inject-initial-data';
import { Users } from './fixtures/userStates';
import { Login } from './page-objects';
import { AccountSectionsHref } from './page-objects/account';
import { AccountManageDevices } from './page-objects/account.manage-devices';
import { test, expect } from './utils/test';
test.describe('Account Manage Devices Page', () => {
test.skip(!IS_EE);
test.use({ storageState: Users.user1.state });
let page: Page;
let accountDevices: AccountManageDevices;
let poLogin: Login;
test.beforeEach(async ({ browser }) => {
({ page } = await createAuxContext(browser, Users.user1));
accountDevices = new AccountManageDevices(page);
poLogin = new Login(page);
await accountDevices.goto();
});
test.afterEach(async () => {
await page.close();
await injectInitialData();
});
test('should show Manage Devices page', async () => {
await expect(accountDevices.pageContent).toBeVisible();
});
test('should logout current device and redirect to login page', async () => {
await accountDevices.table.orderByLastLogin();
const deviceId = await accountDevices.getNthDeviceId(1);
await accountDevices.logoutDeviceById(deviceId);
await poLogin.waitForDisplay();
});
test('should logout other device successfully', async ({ browser }) => {
const context2 = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const page2 = await context2.newPage();
const loginPage2 = new Login(page2);
const accountDevices2 = new AccountManageDevices(page2);
await test.step('should login same user in another session', async () => {
await loginPage2.goto(AccountSectionsHref.manageDevices);
await loginPage2.login('user1', 'password');
await expect(accountDevices2.pageContent).toBeVisible();
});
await test.step('should logout device 1 from session 2', async () => {
await accountDevices2.table.orderByLastLogin();
const device1Id = await accountDevices2.getNthDeviceId(2);
await accountDevices2.logoutDeviceById(device1Id);
await poLogin.waitForDisplay();
await expect(accountDevices2.table.getDeviceRowById(device1Id)).not.toBeVisible();
});
await context2.close();
});
});

View File

@@ -0,0 +1,161 @@
import { faker } from '@faker-js/faker';
import { Users } from './fixtures/userStates';
import {
AccountAccessibility,
AccountFeaturePreview,
AccountOmnichannel,
AccountProfile,
AccountTokens,
HomeChannel,
} from './page-objects';
import { setSettingValueById } from './utils/setSettingValueById';
import { test, expect } from './utils/test';
test.use({ storageState: Users.user3.state });
test.describe.serial('settings-account-profile', () => {
let poHomeChannel: HomeChannel;
let poAccountProfile: AccountProfile;
const token = faker.string.alpha(10);
test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
poAccountProfile = new AccountProfile(page);
});
test.describe('Profile', () => {
test.beforeEach(async () => {
await poAccountProfile.goto();
});
// FIXME: solve test intermitencies
test.skip('expect update profile with new name/username', async () => {
const newName = faker.person.fullName();
const newUsername = faker.internet.userName({ firstName: newName });
await poAccountProfile.inputName.fill(newName);
await poAccountProfile.inputUsername.fill(newUsername);
await poAccountProfile.btnSubmit.click();
await poAccountProfile.btnClose.click();
await poHomeChannel.navbar.openChat('general');
await poHomeChannel.content.sendMessage('any_message');
await expect(poHomeChannel.content.lastUserMessageNotSequential).toContainText(newUsername);
await poHomeChannel.content.lastUserMessageNotSequential.locator('figure').click();
await poHomeChannel.userCard.openUserInfo();
await expect(poHomeChannel.tabs.userInfo.username).toHaveText(newUsername);
});
test.describe('Avatar', () => {
test('should change avatar image by uploading file', async () => {
await poAccountProfile.inputImageFile.setInputFiles('./tests/e2e/fixtures/files/test-image.jpeg');
await poAccountProfile.btnSubmit.click();
await expect(poAccountProfile.userAvatarEditor).toHaveAttribute('src');
});
test('should change avatar image from url', async () => {
await poAccountProfile.inputAvatarLink.fill('https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50');
await poAccountProfile.btnSetAvatarLink.click();
await poAccountProfile.btnSubmit.click();
await expect(poAccountProfile.userAvatarEditor).toHaveAttribute('src');
});
test('should show inline error if the image url is not valid', async () => {
await poAccountProfile.inputAvatarLink.fill('https://invalidUrl');
await poAccountProfile.btnSetAvatarLink.click();
await expect(poAccountProfile.errorInvalidUrl).toBeVisible();
});
test('should show inline error if url does not point to an image', async () => {
await poAccountProfile.inputAvatarLink.fill('https://google.com');
await poAccountProfile.btnSetAvatarLink.click();
await expect(poAccountProfile.errorInvalidUrl).toBeVisible();
});
test('should not allow avatar URL change when avatar changes are disabled', async ({ api }) => {
await setSettingValueById(api, 'Accounts_AllowUserAvatarChange', false);
await expect(poAccountProfile.btnSetAvatarLink).toBeDisabled();
await expect(poAccountProfile.inputAvatarLink).toBeDisabled();
await setSettingValueById(api, 'Accounts_AllowUserAvatarChange', true);
});
});
});
test('Personal Access Tokens', async ({ page }) => {
const poAccountTokens = new AccountTokens(page);
const response = page.waitForResponse('**/api/v1/users.getPersonalAccessTokens');
await poAccountTokens.goto();
await response;
await test.step('should show empty personal access tokens table', async () => {
await expect(poAccountTokens.tokensTableEmpty).toBeVisible();
await expect(poAccountTokens.inputToken).toBeVisible();
});
await test.step('should show new personal token', async () => {
await poAccountTokens.inputToken.fill(token);
await poAccountTokens.btnTokensAdd.click();
await expect(poAccountTokens.tokenAddedModal).toBeVisible();
await poAccountTokens.btnTokenAddedOk.click();
});
await test.step('should not allow add new personal with no name', async () => {
await poAccountTokens.btnTokensAdd.click();
await expect(page.getByRole('alert').filter({ hasText: 'Please provide a name for your token' })).toBeVisible();
});
await test.step('should not allow add new personal token with same name', async () => {
await poAccountTokens.inputToken.fill(token);
await poAccountTokens.btnTokensAdd.click();
await expect(poAccountTokens.tokensRows).toHaveCount(1);
});
await test.step('should regenerate personal token', async () => {
await poAccountTokens.tokenInTable(token).locator('button >> nth=0').click();
await poAccountTokens.btnRegenerateTokenModal.click();
await expect(poAccountTokens.tokenAddedModal).toBeVisible();
await poAccountTokens.btnTokenAddedOk.click();
});
await test.step('should delete personal token', async () => {
await poAccountTokens.tokenInTable(token).locator('button >> nth=1').click();
await poAccountTokens.btnRemoveTokenModal.click();
await expect(poAccountTokens.tokensTableEmpty).toBeVisible();
});
});
test.describe('Omnichannel', () => {
test('should not have any accessibility violations', async ({ page, makeAxeBuilder }) => {
await new AccountOmnichannel(page).goto();
const results = await makeAxeBuilder().analyze();
expect(results.violations).toEqual([]);
});
});
test.describe('Feature Preview', () => {
test('should not have any accessibility violations', async ({ page, makeAxeBuilder }) => {
await new AccountFeaturePreview(page).goto();
const results = await makeAxeBuilder().analyze();
expect(results.violations).toEqual([]);
});
});
test.describe('Accessibility & Appearance', () => {
test('should not have any accessibility violations', async ({ page, makeAxeBuilder }) => {
await new AccountAccessibility(page).goto();
const results = await makeAxeBuilder().analyze();
expect(results.violations).toEqual([]);
});
});
});

View File

@@ -0,0 +1,114 @@
import { faker } from '@faker-js/faker';
import { ADMIN_CREDENTIALS } from './config/constants';
import { Users } from './fixtures/userStates';
import { AccountSecurity } from './page-objects';
import { setSettingValueById, updateOwnUserPassword } from './utils';
import { test, expect } from './utils/test';
test.use({ storageState: Users.admin.state });
const RANDOM_PASSWORD = faker.helpers
.shuffle([
faker.string.alpha({ casing: 'upper' }),
faker.string.alpha({ casing: 'lower' }),
faker.string.numeric(),
faker.string.symbol(),
faker.string.alphanumeric(10),
])
.join('');
test.describe.serial('account-security', () => {
let poAccountSecurity: AccountSecurity;
test.beforeEach(async ({ page, api }) => {
poAccountSecurity = new AccountSecurity(page);
await poAccountSecurity.gotoExpecting(poAccountSecurity.securityHeader.or(poAccountSecurity.notAuthorizedMessage));
await setSettingValueById(api, 'Accounts_Password_Policy_Enabled', false);
});
test.afterAll(async ({ api }) =>
Promise.all([
setSettingValueById(api, 'Accounts_AllowPasswordChange', true),
setSettingValueById(api, 'Accounts_TwoFactorAuthentication_Enabled', true),
setSettingValueById(api, 'E2E_Enable', false),
setSettingValueById(api, 'Accounts_Password_Policy_Enabled', true),
]),
);
test('should disable and enable email 2FA', async () => {
await poAccountSecurity.security2FASection.click();
await expect(poAccountSecurity.email2FASwitch).toBeVisible();
await poAccountSecurity.email2FASwitch.click();
await poAccountSecurity.toastMessage.waitForDisplay();
await poAccountSecurity.toastMessage.dismissToast();
await poAccountSecurity.email2FASwitch.click();
await poAccountSecurity.toastMessage.waitForDisplay();
});
test('should be able to change password', async ({ api }) => {
await test.step('change password', async () => {
await poAccountSecurity.changePassword(RANDOM_PASSWORD, RANDOM_PASSWORD, ADMIN_CREDENTIALS.password);
await expect(poAccountSecurity.inputNewPassword).toHaveValue('');
});
await test.step('change back to the original password', async () => {
expect(
(await updateOwnUserPassword(api, { newPassword: ADMIN_CREDENTIALS.password, currentPassword: RANDOM_PASSWORD })).status(),
).toBe(200);
});
});
test.describe('settings disabled', () => {
test.beforeAll(async ({ api }) => {
await Promise.all([
setSettingValueById(api, 'Accounts_AllowPasswordChange', false),
setSettingValueById(api, 'Accounts_TwoFactorAuthentication_Enabled', false),
setSettingValueById(api, 'E2E_Enable', false),
]);
});
test('security tab is invisible when password change, 2FA and E2E are disabled', async () => {
await expect(poAccountSecurity.sidebar.linkSecurity).not.toBeVisible();
await expect(poAccountSecurity.notAuthorizedMessage).toBeVisible();
});
});
test.describe('account security sections', () => {
test.beforeAll(async ({ api }) => {
await Promise.all([
setSettingValueById(api, 'Accounts_AllowPasswordChange', true),
setSettingValueById(api, 'Accounts_TwoFactorAuthentication_Enabled', false),
setSettingValueById(api, 'E2E_Enable', false),
]);
});
test.beforeEach(async () => {
await poAccountSecurity.securityHeader.waitFor({ state: 'visible' });
});
test('should display security tab and section when password change is enabled but 2FA and E2E are disabled', async () => {
await expect(poAccountSecurity.sidebar.linkSecurity).toBeVisible();
await expect(poAccountSecurity.securityPasswordSection).toBeVisible();
});
test('can access 2FA setting when enabled but password change and E2E are disabled', async ({ api }) => {
await Promise.all([
setSettingValueById(api, 'Accounts_AllowPasswordChange', false),
setSettingValueById(api, 'Accounts_TwoFactorAuthentication_Enabled', true),
setSettingValueById(api, 'E2E_Enable', false),
]);
await expect(poAccountSecurity.security2FASection).toBeVisible();
});
test('can access E2E setting when enabled but password change and 2FA are disabled', async ({ api }) => {
await Promise.all([
setSettingValueById(api, 'Accounts_AllowPasswordChange', false),
setSettingValueById(api, 'Accounts_TwoFactorAuthentication_Enabled', false),
setSettingValueById(api, 'E2E_Enable', true),
]);
await expect(poAccountSecurity.securityE2EEncryptionSection).toBeVisible();
});
});
});

View File

@@ -0,0 +1,93 @@
import { Users } from './fixtures/userStates';
import { Authenticated } from './page-objects/auth';
import { test, expect } from './utils/test';
// We mock the REST `method.call` response instead of registering a real
// Meteor method that requires 2FA: there isn't one in the codebase right now
// (every twoFactorRequired/`requireSecondFactor: true` user-facing surface is
// a typed REST endpoint), and the bug we're guarding against lives inside
// `ddpOverREST`'s `.catch` — a unit-style server fixture exercises it more
// reliably than wiring up TOTP for a real account.
test.use({ storageState: Users.admin.state });
const METHOD_NAME = 'rc-test-totp-flow';
test.describe('account-totp ddpOverREST preserves the totp-required error', () => {
test('opens the TOTP modal when the REST envelope carries a `totp-required` DDP frame, and resolves on retry', async ({ page }) => {
await new Authenticated(page).goto();
let calls = 0;
await page.route(`**/api/v1/method.call/${METHOD_NAME}`, async (route) => {
calls += 1;
const body = JSON.parse(route.request().postData() ?? '{}');
const message = JSON.parse(body.message);
const lastParam = message.params[message.params.length - 1];
const hasTotp = lastParam && typeof lastParam === 'object' && lastParam.twoFactorCode && lastParam.twoFactorMethod === 'totp';
if (!hasTotp) {
// First leg: server demands a TOTP code. The body is shaped exactly
// like `mountResult({ id, error })` in server/api/v1/misc.ts —
// `message` is a DDP-encoded `result` frame whose `error.error`
// carries the original `totp-required` code. The bug under test
// rebuilt this envelope as `error: 'unknown'` and the 2FA modal
// never opened.
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
success: false,
message: JSON.stringify({
msg: 'result',
id: message.id,
error: {
isClientSafe: true,
error: 'totp-required',
reason: 'TOTP Required',
message: 'TOTP Required [totp-required]',
errorType: 'Meteor.Error',
details: { method: 'totp', availableMethods: ['totp'] },
},
}),
}),
});
return;
}
// Second leg: client retried with the code; respond success.
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
message: JSON.stringify({
msg: 'result',
id: message.id,
result: { ok: true, code: lastParam.twoFactorCode },
}),
}),
});
});
// Drive the call from the page so it goes through Meteor.callAsync →
// withAsyncTOTP → ddpOverREST → process2faAsyncReturn — the exact path
// where the catch handler used to drop `error: 'totp-required'` and the
// TOTP modal therefore never opened.
await page.evaluate((method) => {
(window as any).__totpCallResult = (window as any).Meteor.callAsync(method)
.then((value: unknown) => ({ ok: true, value }))
.catch((err: unknown) => ({ ok: false, error: String((err as Error)?.message ?? err) }));
}, METHOD_NAME);
const totpModal = page.getByRole('dialog', { name: 'Enter TOTP password' });
await expect(totpModal).toBeVisible({ timeout: 10_000 });
await totpModal.getByPlaceholder('Enter code here').fill('123456');
await totpModal.getByRole('button', { name: 'Verify' }).click();
await expect(totpModal).toBeHidden();
const result = await page.evaluate(() => (window as any).__totpCallResult);
expect(result).toEqual({ ok: true, value: { ok: true, code: '123456' } });
expect(calls).toBe(2);
});
});

View File

@@ -0,0 +1,118 @@
import type { Page } from 'playwright-core';
import { IS_EE } from './config/constants';
import { createAuxContext } from './fixtures/createAuxContext';
import injectInitialData from './fixtures/inject-initial-data';
import { Users } from './fixtures/userStates';
import { Authenticated, HomeChannel, Login, Registration } from './page-objects';
import { AdminDeviceManagement } from './page-objects/admin-device-management';
import { test, expect } from './utils/test';
test.describe('Admin Device Management Page', () => {
test.skip(!IS_EE);
test.use({ storageState: Users.admin.state });
let page: Page;
let adminDeviceManagement: AdminDeviceManagement;
let poLogin: Login;
test.beforeEach(async ({ browser }) => {
({ page } = await createAuxContext(browser, Users.admin));
adminDeviceManagement = new AdminDeviceManagement(page);
poLogin = new Login(page);
await adminDeviceManagement.goto();
});
test.afterEach(async () => {
await page.close();
await injectInitialData();
});
test('should show Device management page', async () => {
await expect(adminDeviceManagement.pageContent).toBeVisible();
});
test('should logout current device and redirect to login page', async () => {
const deviceId = await adminDeviceManagement.getUsersDeviceId('rocketchat.internal.admin.test');
await adminDeviceManagement.logoutDeviceById(deviceId);
await poLogin.waitForDisplay();
});
test('should logout current device from device info tab and redirect to login page', async () => {
const deviceId = await adminDeviceManagement.getUsersDeviceId('rocketchat.internal.admin.test');
await adminDeviceManagement.searchUserDevice('rocketchat.internal.admin.test');
await adminDeviceManagement.table.getDeviceRowById(deviceId).click();
await expect(adminDeviceManagement.deviceInfo.getDeviceInfoId(deviceId)).toBeVisible();
await adminDeviceManagement.deviceInfo.btnLogoutDevice.click();
await adminDeviceManagement.logoutModal.confirmLogout();
await poLogin.waitForDisplay();
});
test('should logout other device successfully', async ({ browser }) => {
const user2Page = await browser.newPage({ storageState: Users.user2.state });
const loginPage2 = new Registration(user2Page);
await new Authenticated(user2Page).goto();
const user2DeviceId = await adminDeviceManagement.getUsersDeviceId('user2');
await test.step('should logout user2 and redirect to login page', async () => {
await adminDeviceManagement.logoutDeviceById(user2DeviceId);
await loginPage2.waitForDisplay();
});
await test.step('should no longer show user2 device in admin device management page', async () => {
await adminDeviceManagement.searchUserDevice('user2');
await expect(adminDeviceManagement.table.getDeviceRowById(user2DeviceId)).not.toBeVisible();
});
await user2Page.close();
});
test('should remove user2 from device list when user2 logs out from their own session', async ({ browser }) => {
const user2Page = await browser.newPage({ storageState: Users.user2.state });
const loginPage2 = new Registration(user2Page);
const poUser2Home = new HomeChannel(user2Page);
await poUser2Home.goto();
await test.step('should list user2 device while user2 is logged in', async () => {
await expect(adminDeviceManagement.pageContent).toBeVisible();
await adminDeviceManagement.searchUserDevice('user2');
await expect(adminDeviceManagement.table.getRowsForUsername('user2')).toHaveCount(1);
});
await test.step('should log user2 out from the app and redirect to login page', async () => {
await poUser2Home.navbar.logout();
await loginPage2.waitForDisplay();
});
await test.step('should no longer show user2 device in admin device management page', async () => {
await page.reload();
await expect(adminDeviceManagement.pageContent).toBeVisible();
await adminDeviceManagement.searchUserDevice('user2');
await expect(adminDeviceManagement.table.getRowsForUsername('user2')).toHaveCount(0);
});
await user2Page.close();
});
test('Should show empty state when searching for user without result', async () => {
const noResultQuery = 'nonexistentuser';
await adminDeviceManagement.searchUserDevice('noResultQuery');
await expect(adminDeviceManagement.table.getRowsForUsername(noResultQuery)).toHaveCount(0);
await expect(adminDeviceManagement.emptyState).toBeVisible();
});
});
test.describe('Admin Device Management Page - unauthorized access', () => {
test.skip(!IS_EE);
test.use({ storageState: Users.user2.state });
test('should not access device-management when user has no view-device-management permission', async ({ page }) => {
const user2DeviceManagement = new AdminDeviceManagement(page);
await user2DeviceManagement.gotoExpecting(user2DeviceManagement.notAuthorizedMessage);
await expect(user2DeviceManagement.notAuthorizedMessage).toBeVisible();
await expect(user2DeviceManagement.pageContent).not.toBeVisible();
});
});

View File

@@ -0,0 +1,94 @@
import { faker } from '@faker-js/faker';
import { Users } from './fixtures/userStates';
import { AdminInfo, AdminRooms, AdminSectionsHref } from './page-objects';
import { createTargetChannel, createTargetPrivateChannel } from './utils';
import { expect, test } from './utils/test';
test.use({ storageState: Users.admin.state });
test.describe.serial('admin-rooms', () => {
let channel: string;
let privateRoom: string;
let adminRooms: AdminRooms;
let adminInfo: AdminInfo;
test.beforeEach(async ({ page }) => {
adminRooms = new AdminRooms(page);
await adminRooms.goto();
});
test.beforeAll(async ({ api }) => {
[channel, privateRoom] = await Promise.all([createTargetChannel(api), createTargetPrivateChannel(api)]);
});
test('should display the Rooms Table', async ({ page }) => {
await expect(page.getByRole('main').getByRole('heading', { level: 1, name: 'Rooms', exact: true })).toBeVisible();
await expect(page.getByRole('main').getByRole('table')).toBeVisible();
});
test('should filter room by name', async ({ page }) => {
await adminRooms.inputSearchRooms.fill(channel);
await expect(page.locator(`[qa-room-name="${channel}"]`)).toBeVisible();
});
test('should filter rooms by type', async ({ page }) => {
const dropdown = await adminRooms.dropdownFilterRoomType();
await dropdown.click();
const privateOption = page.locator('text=Private channels');
await privateOption.waitFor();
await privateOption.click();
const selectedDropdown = await adminRooms.dropdownFilterRoomType('Rooms (1)');
await expect(selectedDropdown).toBeVisible();
await expect(page.locator('text=Private Channel').first()).toBeVisible();
});
test('should filter rooms by type and name', async ({ page }) => {
await adminRooms.inputSearchRooms.fill(privateRoom);
const dropdown = await adminRooms.dropdownFilterRoomType();
await dropdown.click();
await page.locator('text=Private channels').click();
await expect(page.locator(`[qa-room-name="${privateRoom}"]`)).toBeVisible();
});
test('should be empty in case of the search does not find any room', async ({ page }) => {
const nonExistingChannel = faker.string.alpha(10);
await adminRooms.inputSearchRooms.fill(nonExistingChannel);
const dropdown = await adminRooms.dropdownFilterRoomType();
await dropdown.click();
await page.locator('text=Private channels').click();
await expect(page.locator('text=No results found')).toBeVisible();
});
test('should filter rooms by type and name and clean the filter after changing section', async ({ page }) => {
adminInfo = new AdminInfo(page);
await adminRooms.inputSearchRooms.fill(privateRoom);
const dropdown = adminRooms.dropdownFilterRoomType();
await dropdown.click();
await page.locator('text=Private channels').click();
const workspaceButton = await adminRooms.adminSectionButton(AdminSectionsHref.workspace);
await workspaceButton.click();
await expect(adminInfo.pageContent).toBeVisible();
const roomsButton = await adminRooms.adminSectionButton(AdminSectionsHref.rooms);
await roomsButton.click();
const selectDropdown = await adminRooms.dropdownFilterRoomType('All rooms');
await expect(selectDropdown).toBeVisible();
});
});

View File

@@ -0,0 +1,157 @@
import { Users } from './fixtures/userStates';
import { AdminUsers, HomeChannel } from './page-objects';
import { test, expect } from './utils/test';
import { createTestUser, type ITestUser } from './utils/user-helpers';
const customFieldInitial1 = 'initial1';
const adminCustomFieldValue1 = 'admin_value1';
const adminCustomFieldValue2 = 'admin_value2';
const adminCustomFieldUpdated1 = 'updated_admin1';
test.describe('Admin users custom fields', () => {
let poHomeChannel: HomeChannel;
let poAdmin: AdminUsers;
let addTestUser: ITestUser;
let updateTestUser: ITestUser;
test.use({ storageState: Users.admin.state });
test.beforeAll(async ({ api }) => {
await api.post('/settings/Accounts_CustomFields', {
value: JSON.stringify({
customFieldText1: {
type: 'text',
required: false,
},
customFieldText2: {
type: 'text',
required: false,
},
}),
});
[addTestUser, updateTestUser] = await Promise.all([
createTestUser(api),
createTestUser(api, {
data: {
customFields: {
customFieldText1: customFieldInitial1,
customFieldText2: adminCustomFieldValue2,
},
},
}),
]);
});
test.afterAll(async ({ api }) => {
await Promise.all([
api.post('/settings/Accounts_CustomFields', {
value: '',
}),
addTestUser.delete(),
updateTestUser.delete(),
]);
});
test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
poAdmin = new AdminUsers(page);
await poAdmin.goto();
});
test('should allow admin to add user custom fields', async () => {
await test.step('should find and click on add test user', async () => {
await poAdmin.searchUser(addTestUser.data.username);
await poAdmin.getUserRowByUsername(addTestUser.data.username).click();
});
await test.step('should navigate to edit user form', async () => {
await poAdmin.userInfo.btnEdit.click();
});
await test.step('should fill custom fields for user', async () => {
await poAdmin.editUser.getCustomField('customFieldText1').fill(adminCustomFieldValue1);
await poAdmin.editUser.getCustomField('customFieldText2').fill(adminCustomFieldValue2);
});
await test.step('should save user custom fields', async () => {
await poAdmin.editUser.btnSaveUser.click();
await poHomeChannel.toastMessage.dismissToast();
await poAdmin.editUser.close();
});
await test.step('should verify custom fields were saved', async () => {
await poAdmin.getUserRowByUsername(addTestUser.data.username).click();
await poAdmin.userInfo.btnEdit.click();
await expect(poAdmin.editUser.getCustomField('customFieldText1')).toHaveValue(adminCustomFieldValue1);
await expect(poAdmin.editUser.getCustomField('customFieldText2')).toHaveValue(adminCustomFieldValue2);
});
});
test('should allow admin to update existing user custom fields', async () => {
await test.step('should find and click on update test user', async () => {
await poAdmin.searchUser(updateTestUser.data.username);
await poAdmin.getUserRowByUsername(updateTestUser.data.username).click();
});
await test.step('should navigate to edit user form', async () => {
await poAdmin.userInfo.btnEdit.click();
});
await test.step('should verify existing values and update one custom field', async () => {
await poAdmin.editUser.inputName.waitFor();
await expect(poAdmin.editUser.getCustomField('customFieldText1')).toHaveValue(customFieldInitial1);
await expect(poAdmin.editUser.getCustomField('customFieldText2')).toHaveValue(adminCustomFieldValue2);
await poAdmin.editUser.getCustomField('customFieldText1').clear();
await poAdmin.editUser.getCustomField('customFieldText1').fill(adminCustomFieldUpdated1);
});
await test.step('should save and verify partial update', async () => {
await poAdmin.editUser.btnSaveUser.click();
await poHomeChannel.toastMessage.dismissToast();
await poAdmin.editUser.close();
await poAdmin.getUserRowByUsername(updateTestUser.data.username).click();
await poAdmin.userInfo.btnEdit.click();
await expect(poAdmin.editUser.getCustomField('customFieldText1')).toHaveValue(adminCustomFieldUpdated1);
await expect(poAdmin.editUser.getCustomField('customFieldText2')).toHaveValue(adminCustomFieldValue2);
});
});
test.describe('with invalid custom field type', () => {
test.beforeAll(async ({ api }) => {
await api.post('/settings/Accounts_CustomFields', {
value: JSON.stringify({
customFieldText1: {
type: 'invalid_type',
required: false,
},
customFieldText2: {
type: 'text',
required: false,
},
}),
});
});
test('should not render fields with invalid custom field type', async () => {
await test.step('should find and click on add test user', async () => {
await poAdmin.searchUser(addTestUser.data.username);
await poAdmin.getUserRowByUsername(addTestUser.data.username).click();
});
await test.step('should navigate to edit user form', async () => {
await poAdmin.userInfo.btnEdit.click();
});
await test.step('should verify custom field is not rendered', async () => {
await expect(poAdmin.editUser.getCustomField('customFieldText1')).not.toBeVisible();
await expect(poAdmin.editUser.getCustomField('customFieldText2')).toBeVisible();
});
});
});
});

View File

@@ -0,0 +1,66 @@
import { Users } from './fixtures/userStates';
import { AdminUsers } from './page-objects';
import { test, expect } from './utils/test';
import type { ITestUser } from './utils/user-helpers';
import { createTestUser } from './utils/user-helpers';
let userWithoutAdminAccess: ITestUser;
let userWithAdminAccess: ITestUser;
let admin: AdminUsers;
test.describe('Admin > Users Role Management', () => {
test.beforeAll('Create test users', async ({ api }) => {
userWithoutAdminAccess = await createTestUser(api);
userWithAdminAccess = await createTestUser(api, { roles: ['admin'] });
});
test.afterAll('Delete test users', async () => {
await userWithoutAdminAccess.delete();
await userWithAdminAccess.delete();
});
test.describe('Admin Role Management', () => {
test.use({ storageState: Users.admin.state });
test.beforeEach('Go to /admin/users', async ({ page }) => {
admin = new AdminUsers(page);
await admin.goto();
});
test('Make a newly created user as admin', async () => {
await test.step('should be visible in the All tab', async () => {
await admin.getTabByName().click();
await admin.searchUser(userWithoutAdminAccess.data.username);
});
await test.step('make a user admin', async () => {
await admin.dispatchUserAction(userWithoutAdminAccess.data.username, 'Make Admin');
await admin.toastMessage.waitForDisplay({ type: 'success', message: 'User is now an admin' });
});
await test.step('verify user is admin', async () => {
await admin.getUserRowByUsername(userWithoutAdminAccess.data.username).click();
await expect(admin.userInfo.root).toBeVisible();
await expect(admin.userInfo.root).toContainText('Admin');
});
});
test('Remove role as admin', async () => {
await test.step('User should be visible in the All tab', async () => {
await admin.getTabByName().click();
await admin.searchUser(userWithAdminAccess.data.username);
});
await test.step('remove admin role', async () => {
await admin.dispatchUserAction(userWithAdminAccess.data.username, 'Remove Admin');
await admin.toastMessage.waitForDisplay({ type: 'success', message: 'User is no longer an admin' });
});
await test.step('verify user role as admin is removed', async () => {
await admin.getUserRowByUsername(userWithAdminAccess.data.username).click();
await expect(admin.userInfo.root).toBeVisible();
await expect(admin.userInfo.root).not.toHaveText('Admin');
});
});
});
});

View File

@@ -0,0 +1,80 @@
import type { BrowserContext, Page } from '@playwright/test';
import { DEFAULT_USER_CREDENTIALS } from './config/constants';
import { Users } from './fixtures/userStates';
import { AdminUsers, Authenticated, Login } from './page-objects';
import { test, expect } from './utils/test';
import type { ITestUser } from './utils/user-helpers';
import { createTestUser } from './utils/user-helpers';
let user: ITestUser;
let adminUsers: AdminUsers;
test.describe.serial('Admin > Users', () => {
test.beforeAll('Create a new user', async ({ api }) => {
user = await createTestUser(api);
});
test.afterAll('Delete the new user', async () => {
await user.delete();
});
test.describe('Login', () => {
let context: BrowserContext;
let page: Page;
let poLogin: Login;
let poAuth: Authenticated;
test.beforeAll(async ({ browser }) => {
context = await browser.newContext();
page = await context.newPage();
poLogin = new Login(page);
poAuth = new Authenticated(page);
});
test.afterAll(async () => {
await context.close();
});
test('Login as a newly created user and verify its status is active', async () => {
await poLogin.goto('/login');
await test.step('should log in as the newly created user', async () => {
await poLogin.login(user.data.username, DEFAULT_USER_CREDENTIALS.password);
});
await test.step('Assert user is logged in', async () => {
await poAuth.waitForDisplay();
});
});
});
test.describe('Admin > Users Status Management', () => {
test.use({ storageState: Users.admin.state });
test.beforeEach('Go to /admin/users', async ({ page }) => {
adminUsers = new AdminUsers(page);
await adminUsers.goto();
});
test('After the first login, the user gets listed under the Active tab', async () => {
await test.step('should be visible in the All tab', async () => {
await adminUsers.getTabByName().click();
await adminUsers.searchUser(user.data.username);
});
await test.step('should not be visible in the Pending tab', async () => {
await adminUsers.getTabByName('Pending').click();
await expect(adminUsers.getUserRowByUsername(user.data.username)).not.toBeVisible();
});
await test.step('should be visible in the Active tab', async () => {
await adminUsers.getTabByName('Active').click();
await expect(adminUsers.getUserRowByUsername(user.data.username)).toBeVisible();
});
await test.step('should not be visible in the Deactivated tab', async () => {
await adminUsers.getTabByName('Deactivated').click();
await expect(adminUsers.getUserRowByUsername(user.data.username)).not.toBeVisible();
});
});
});
});

View File

@@ -0,0 +1,66 @@
import { Users } from './fixtures/userStates';
import { AdminUsers } from './page-objects';
import { setSettingValueById } from './utils/setSettingValueById';
import { test, expect } from './utils/test';
import type { ITestUser } from './utils/user-helpers';
import { createTestUser } from './utils/user-helpers';
let user: ITestUser;
let admin: AdminUsers;
test.use({ storageState: Users.admin.state });
test.describe('Admin > Users', () => {
test.beforeAll('Create a new user', async ({ api }) => {
await setSettingValueById(api, 'Accounts_ManuallyApproveNewUsers', true);
user = await createTestUser(api);
});
test.afterAll('Delete the new user', async ({ api }) => {
await user.delete();
await setSettingValueById(api, 'Accounts_ManuallyApproveNewUsers', false);
});
test.beforeEach('Go to /admin/users', async ({ page }) => {
admin = new AdminUsers(page);
await admin.goto();
});
test('New user shows in correct tabs when deactivated', async () => {
await test.step('should be visible in the All tab', async () => {
await admin.getTabByName().click();
await admin.searchUser(user.data.username);
});
await test.step('should be visible in the Pending tab', async () => {
await admin.getTabByName('Pending').click();
await expect(admin.getUserRowByUsername(user.data.username)).toBeVisible();
});
await test.step('should not be visible in the Active tab', async () => {
await admin.getTabByName('Active').click();
await expect(admin.getUserRowByUsername(user.data.username)).not.toBeVisible();
});
await test.step('should not be visible in the Deactivated tab', async () => {
await admin.getTabByName('Deactivated').click();
await expect(admin.getUserRowByUsername(user.data.username)).not.toBeVisible();
});
await test.step('should move from Pending to Active tab', async () => {
await admin.getTabByName('Pending').click();
await admin.activatePendingUser(user.data.username);
await expect(admin.getUserRowByUsername(user.data.username)).not.toBeVisible();
await admin.getTabByName('Active').click();
await expect(admin.getUserRowByUsername(user.data.username)).toBeVisible();
});
await test.step('should move from Active to Deactivated tab', async () => {
await admin.getTabByName('Active').click();
await admin.dispatchUserAction(user.data.username, 'Deactivate');
await expect(admin.getUserRowByUsername(user.data.username)).not.toBeVisible();
await admin.getTabByName('Deactivated').click();
await expect(admin.getUserRowByUsername(user.data.username)).toBeVisible();
});
});
});

View File

@@ -0,0 +1,101 @@
import { Users } from './fixtures/userStates';
import { AdminSettings, AdminSectionsHref } from './page-objects';
import { Navbar } from './page-objects/fragments';
import { LoginPage } from './page-objects/login';
import { getSettingValueById, setSettingValueById } from './utils';
import { test, expect } from './utils/test';
test.use({ storageState: Users.admin.state });
test.describe.parallel('administration-settings', () => {
let poAdminSettings: AdminSettings;
let navbar: Navbar;
let poLoginPage: LoginPage;
test.beforeEach(async ({ page }) => {
poAdminSettings = new AdminSettings(page);
navbar = new Navbar(page);
poLoginPage = new LoginPage(page);
});
test.describe('Settings Page', () => {
test.beforeEach(async () => {
await poAdminSettings.goto();
});
test('should display settings list', async ({ page }) => {
await test.step('should list settings on load', async () => {
await expect(page.getByText('No results found')).not.toBeVisible();
});
await test.step('should list settings after logout and login', async () => {
await poAdminSettings.sidebar.close();
await navbar.logout();
await poLoginPage.loginByUserState(Users.admin);
await navbar.openAdminPanel();
const settingsButton = await poAdminSettings.adminSectionButton(AdminSectionsHref.settings);
await settingsButton.click();
await expect(page.getByText('No results found')).not.toBeVisible();
});
});
});
test.describe('General', () => {
let inputSiteURLSetting: string;
test.beforeAll(async ({ api }) => {
inputSiteURLSetting = (await getSettingValueById(api, 'Site_Url')) as string;
});
test.beforeEach(async () => {
await poAdminSettings.gotoGeneral();
});
test('should be able to reset a setting after a change', async () => {
await poAdminSettings.inputSiteURL.fill('any_text');
await poAdminSettings.btnResetSiteURL.click();
await expect(poAdminSettings.inputSiteURL).toHaveValue(inputSiteURLSetting);
});
test('should be able to go back to the settings page', async ({ page }) => {
await poAdminSettings.btnBack.click();
await expect(page).toHaveURL('/admin/settings');
});
});
test.describe('Layout', () => {
test.beforeEach(async () => {
await poAdminSettings.gotoLayout();
});
test.afterAll(async ({ api }) => setSettingValueById(api, 'theme-custom-css', ''));
test('should display the code mirror correctly', async ({ page, api }) => {
await poAdminSettings.getAccordionBtnByName('Custom CSS').click();
await test.step('should render only one code mirror element', async () => {
const codeMirrorParent = page.getByRole('code');
await expect(codeMirrorParent.locator('.CodeMirror')).toHaveCount(1);
});
await test.step('should display full screen properly', async () => {
await poAdminSettings.btnFullScreen.click();
await expect(page.getByRole('code')).toHaveCSS('width', '920px');
await poAdminSettings.btnExitFullScreen.click();
});
await test.step('should reflect updated value when valueProp changes after server update', async () => {
const codeValue = `.test-class-${Date.now()} { background-color: red; }`;
await setSettingValueById(api, 'theme-custom-css', codeValue);
const codeMirrorParent = page.getByRole('code');
await expect(codeMirrorParent.locator('.CodeMirror-line')).toHaveText(codeValue);
});
});
});
});

View File

@@ -0,0 +1,469 @@
import { faker } from '@faker-js/faker';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { IS_EE } from './config/constants';
import { Users } from './fixtures/userStates';
import {
AdminUsers,
AdminPermissions,
AdminRooms,
AdminThirdPartyLogin,
AdminIntegrations,
AdminInfo,
AdminEngagement,
AdminMailer,
} from './page-objects';
import { AdminDeviceManagement } from './page-objects/admin-device-management';
import { ToastMessages } from './page-objects/fragments';
import { createTargetChannel, setSettingValueById } from './utils';
import { test, expect } from './utils/test';
test.use({ storageState: Users.admin.state });
test.describe.parallel('administration', () => {
let targetChannel: string;
test.describe('Workspace', () => {
test.beforeEach(async ({ page }) => {
await new AdminInfo(page).goto();
});
test('expect download info as JSON', async ({ page }) => {
const [download] = await Promise.all([page.waitForEvent('download'), page.locator('button:has-text("Download info")').click()]);
await expect(download.suggestedFilename()).toBe('statistics.json');
});
});
test.describe('Engagement dashboard', () => {
test('Should show upsell modal', async ({ page }) => {
test.skip(IS_EE);
const poAdminEngagement = new AdminEngagement(page);
await poAdminEngagement.gotoExpecting(poAdminEngagement.upsellModal);
await expect(poAdminEngagement.upsellModal).toBeVisible();
});
test('Should show engagement dashboard', async ({ page }) => {
test.skip(!IS_EE);
await new AdminEngagement(page).goto();
await expect(page.locator('h1 >> text="Engagement"')).toBeVisible();
});
});
test.describe('Device management', () => {
test('Should show upsell modal', async ({ page }) => {
test.skip(IS_EE);
const poAdminDeviceManagement = new AdminDeviceManagement(page);
await poAdminDeviceManagement.gotoExpecting(poAdminDeviceManagement.upsellModal);
await expect(poAdminDeviceManagement.upsellModal).toBeVisible();
});
test('Should show device management page', async ({ page }) => {
test.skip(!IS_EE);
await new AdminDeviceManagement(page).goto();
await expect(page.locator('h1 >> text="Device management"')).toBeVisible();
});
});
test.describe('Users', () => {
let poAdminUsers: AdminUsers;
test.beforeEach(async ({ page }) => {
poAdminUsers = new AdminUsers(page);
await poAdminUsers.goto();
});
test('expect find "user1" user', async () => {
await poAdminUsers.searchUser('user1');
});
test('expect create a user', async () => {
await poAdminUsers.btnNewUser.click();
await poAdminUsers.editUser.inputEmail.fill(faker.internet.email());
await poAdminUsers.editUser.inputName.fill(faker.person.firstName());
await poAdminUsers.editUser.inputUserName.fill(faker.internet.userName());
await poAdminUsers.editUser.inputSetManually.click();
await poAdminUsers.editUser.inputPassword.fill('P@ssw0rd1234.!');
await poAdminUsers.editUser.inputConfirmPassword.fill('P@ssw0rd1234.!');
await expect(poAdminUsers.editUser.chipUserRole).toBeVisible();
await poAdminUsers.editUser.btnAddUser.click();
});
test('expect SMTP setup warning and routing to email settings', async ({ page }) => {
await poAdminUsers.btnInvite.click();
await poAdminUsers.editUser.setupSmtpLink.click();
await expect(page).toHaveURL('/admin/settings/Email');
});
test('expect to show join default channels option only when creating new users, not when editing users', async () => {
const username = faker.internet.userName();
await poAdminUsers.btnNewUser.click();
await poAdminUsers.editUser.inputName.fill(faker.person.firstName());
await poAdminUsers.editUser.inputUserName.fill(username);
await poAdminUsers.editUser.inputEmail.fill(faker.internet.email());
await poAdminUsers.editUser.inputSetManually.click();
await poAdminUsers.editUser.inputPassword.fill('P@ssw0rd1234.!');
await poAdminUsers.editUser.inputConfirmPassword.fill('P@ssw0rd1234.!');
await expect(poAdminUsers.editUser.chipUserRole).toBeVisible();
await expect(poAdminUsers.editUser.joinDefaultChannels).toBeVisible();
await poAdminUsers.editUser.btnAddUser.click();
await poAdminUsers.searchUser(username);
await poAdminUsers.getUserRowByUsername(username).click();
await poAdminUsers.userInfo.btnEdit.click();
await expect(poAdminUsers.editUser.inputUserName).toHaveValue(username);
await expect(poAdminUsers.editUser.joinDefaultChannels).not.toBeVisible();
});
test.describe('Delete user', () => {
const nonEmptyChannelName = faker.string.uuid();
const emptyChannelName = faker.string.uuid();
let ownerUser: IUser;
let user: IUser;
let poToastMessage: ToastMessages;
test.beforeEach(({ page }) => {
poToastMessage = new ToastMessages(page);
});
test.beforeAll(async ({ api }) => {
const createUserResponse = await api.post('/users.create', {
email: faker.internet.email(),
name: faker.person.fullName(),
password: faker.internet.password(),
username: faker.internet.userName(),
});
user = (await createUserResponse.json()).user;
const createOwnerUserResponse = await api.post('/users.create', {
email: faker.internet.email(),
name: faker.person.fullName(),
password: faker.internet.password(),
username: faker.internet.userName(),
});
ownerUser = (await createOwnerUserResponse.json()).user;
// TODO: refactor createChannel utility in order to get channel data when creating
const response = await api.post('/channels.create', { name: nonEmptyChannelName, members: [ownerUser.username] });
const { channel: nonEmptyChannel } = await response.json();
await api.post('/channels.addOwner', { roomId: nonEmptyChannel._id, username: ownerUser.username });
await api.post('/channels.removeOwner', { roomId: nonEmptyChannel._id, userId: Users.admin.data._id });
// TODO: refactor createChannel utility in order to get channel data when creating
const res = await api.post('/groups.create', { name: emptyChannelName, members: [ownerUser.username] });
const { group: emptyRoom } = await res.json();
await api.post('/groups.addOwner', { roomId: emptyRoom._id, username: ownerUser.username });
await api.post('/groups.leave', { roomId: emptyRoom._id });
});
test('expect to show owner change modal, when deleting last owner of any room', async ({ page }) => {
await poAdminUsers.deleteUser(ownerUser.username);
await expect(page.getByRole('dialog', { name: 'Are you sure?' })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('dialog', { name: 'Are you sure?' })).toContainText(
`A new owner will be assigned automatically to the ${nonEmptyChannelName} room.`,
);
await expect(page.getByRole('dialog', { name: 'Are you sure?' })).toContainText(
`The empty room ${emptyChannelName} will be removed automatically.`,
);
await expect(page.getByRole('dialog').getByRole('button', { name: 'Delete' })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await poToastMessage.waitForDisplay();
await expect(page.getByRole('heading', { name: 'No users' })).toBeVisible();
});
test('expect to delete user', async ({ page }) => {
await poAdminUsers.deleteUser(user.username);
await expect(page.getByRole('dialog', { name: 'Are you sure?' })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await poToastMessage.waitForDisplay();
await expect(page.getByRole('heading', { name: 'No users' })).toBeVisible();
});
});
});
// TODO: Move this suit to admin-rooms.spec.ts
test.describe('Rooms', () => {
let poAdminRooms: AdminRooms;
test.beforeAll(async ({ api }) => {
targetChannel = await createTargetChannel(api);
});
test.beforeEach(async ({ page }) => {
poAdminRooms = new AdminRooms(page);
await poAdminRooms.goto();
});
test('should find "general" channel', async ({ page }) => {
await poAdminRooms.inputSearchRooms.type('general');
await page.waitForSelector('[qa-room-id="GENERAL"]');
});
test('should edit target channel name', async () => {
await poAdminRooms.inputSearchRooms.fill(targetChannel);
await poAdminRooms.getRoomRow(targetChannel).click();
await poAdminRooms.editRoom.roomNameInput.fill(`${targetChannel}-edited`);
await poAdminRooms.editRoom.btnSave.click();
await expect(poAdminRooms.getRoomRow(targetChannel)).toContainText(`${targetChannel}-edited`);
targetChannel = `${targetChannel}-edited`;
});
test('should edit target channel type', async () => {
await poAdminRooms.inputSearchRooms.type(targetChannel);
await poAdminRooms.getRoomRow(targetChannel).click();
await poAdminRooms.editRoom.privateLabel.click();
await poAdminRooms.editRoom.btnSave.click();
await expect(poAdminRooms.getRoomRow(targetChannel)).toContainText('Private Channel');
});
test('should archive target channel', async () => {
await poAdminRooms.inputSearchRooms.type(targetChannel);
await poAdminRooms.getRoomRow(targetChannel).click();
await poAdminRooms.editRoom.archivedLabel.click();
await poAdminRooms.editRoom.btnSave.click();
await poAdminRooms.editRoom.waitForDismissal();
await poAdminRooms.getRoomRow(targetChannel).click();
await expect(poAdminRooms.editRoom.archivedInput).toBeChecked();
});
test.describe.serial('Default rooms', () => {
test('expect target channel to be default', async () => {
await poAdminRooms.inputSearchRooms.type(targetChannel);
await poAdminRooms.getRoomRow(targetChannel).click();
await poAdminRooms.editRoom.defaultLabel.click();
await test.step('should close contextualbar after saving', async () => {
await poAdminRooms.editRoom.btnSave.click();
await poAdminRooms.editRoom.waitForDismissal();
});
await poAdminRooms.getRoomRow(targetChannel).click();
await expect(poAdminRooms.editRoom.defaultInput).toBeChecked();
});
test('should mark target default channel as "favorite by default"', async () => {
await poAdminRooms.inputSearchRooms.fill(targetChannel);
await poAdminRooms.getRoomRow(targetChannel).click();
await poAdminRooms.editRoom.favoriteLabel.click();
await poAdminRooms.editRoom.btnSave.click();
await expect(poAdminRooms.editRoom.btnSave).not.toBeVisible();
await poAdminRooms.getRoomRow(targetChannel).click();
await expect(poAdminRooms.editRoom.favoriteInput).toBeChecked();
});
test('should see favorite switch disabled when default is not true', async () => {
await poAdminRooms.inputSearchRooms.fill(targetChannel);
await poAdminRooms.getRoomRow(targetChannel).click();
await poAdminRooms.editRoom.defaultLabel.click();
await expect(poAdminRooms.editRoom.favoriteInput).toBeDisabled();
});
test('should see favorite switch enabled when default is true', async () => {
await poAdminRooms.inputSearchRooms.type(targetChannel);
await poAdminRooms.getRoomRow(targetChannel).click();
await expect(poAdminRooms.editRoom.favoriteInput).toBeEnabled();
});
});
});
test.describe('Permissions', () => {
let poAdminPermissions: AdminPermissions;
test.beforeEach(async ({ page }) => {
poAdminPermissions = new AdminPermissions(page);
await poAdminPermissions.goto();
});
test('expect open upsell modal if not enterprise', async ({ page }) => {
test.skip(IS_EE);
await poAdminPermissions.btnCreateRole.click();
await expect(page.getByRole('dialog', { name: 'Custom roles' })).toBeVisible();
});
test.describe('Users in role', () => {
const channelName = faker.string.uuid();
test.beforeAll(async ({ api }) => {
// TODO: refactor createChannel utility in order to get channel data when creating
const response = await api.post('/channels.create', { name: channelName, members: ['user1'] });
const { channel } = await response.json();
await api.post('/channels.addOwner', { roomId: channel._id, userId: Users.user1.data._id });
await api.post('/channels.removeOwner', { roomId: channel._id, userId: Users.admin.data._id });
});
test('admin should be able to get the owners of a room that wasnt created by him', async ({ page }) => {
await poAdminPermissions.openRoleByName('Owner').click();
await poAdminPermissions.btnUsersInRole.click();
await poAdminPermissions.inputRoom.fill(channelName);
await page.getByRole('option', { name: channelName }).click();
await expect(poAdminPermissions.getUserInRoleRowByUsername('user1')).toBeVisible();
});
test('should add user1 as moderator of target channel', async ({ page }) => {
await poAdminPermissions.openRoleByName('Moderator').click();
await poAdminPermissions.btnUsersInRole.click();
await poAdminPermissions.inputRoom.fill(channelName);
await page.getByRole('option', { name: channelName }).click();
await poAdminPermissions.inputUsers.pressSequentially('user1');
await page.getByRole('option', { name: 'user1' }).click();
await poAdminPermissions.btnAdd.click();
await expect(poAdminPermissions.getUserInRoleRowByUsername('user1')).toBeVisible();
});
test('should remove user1 as moderator of target channel', async ({ page }) => {
await poAdminPermissions.openRoleByName('Moderator').click();
await poAdminPermissions.btnUsersInRole.click();
await poAdminPermissions.inputRoom.fill(channelName);
await page.getByRole('option', { name: channelName }).click();
await poAdminPermissions.removeUserFromRoleByUsername('user1');
await expect(page.locator('h3 >> text="No results found"')).toBeVisible();
});
test('should back to the permissions page', async ({ page }) => {
await poAdminPermissions.openRoleByName('Moderator').click();
await poAdminPermissions.btnUsersInRole.click();
await poAdminPermissions.btnBack.click();
await expect(page.locator('h1 >> text="Permissions"')).toBeVisible();
});
});
});
test.describe('Mailer', () => {
test.beforeEach(async ({ page }) => {
await new AdminMailer(page).goto();
});
test('should not have any accessibility violations', async ({ makeAxeBuilder }) => {
const results = await makeAxeBuilder().analyze();
expect(results.violations).toEqual([]);
});
});
test.describe.serial('Third party login', () => {
let poAdminThirdPartyLogin: AdminThirdPartyLogin;
const appName = faker.string.uuid();
const appRedirectURI = faker.internet.url();
test.beforeEach(async ({ page }) => {
poAdminThirdPartyLogin = new AdminThirdPartyLogin(page);
await poAdminThirdPartyLogin.goto();
});
test('should show Third-party login page', async ({ page }) => {
await expect(page.locator('h1 >> text="Third-party login"')).toBeVisible();
});
test('should not be able to create a new application without application name', async ({ page }) => {
await poAdminThirdPartyLogin.btnNewApplication.click();
await poAdminThirdPartyLogin.inputRedirectURI.fill(appRedirectURI);
await poAdminThirdPartyLogin.btnSave.click();
await expect(page.getByText('Name required')).toBeVisible();
});
test('should not be able to create a new application without redirect URI', async ({ page }) => {
await poAdminThirdPartyLogin.btnNewApplication.click();
await poAdminThirdPartyLogin.inputApplicationName.fill(appName);
await poAdminThirdPartyLogin.btnSave.click();
await expect(page.getByText('Redirect URI required')).toBeVisible();
});
test('should be able to create a new application', async ({ page }) => {
await poAdminThirdPartyLogin.btnNewApplication.click();
await poAdminThirdPartyLogin.inputApplicationName.fill(appName);
await poAdminThirdPartyLogin.inputRedirectURI.fill(appRedirectURI);
await poAdminThirdPartyLogin.btnSave.click();
await expect(poAdminThirdPartyLogin.getThirdPartyAppByName(appName)).toBeVisible();
await expect(page.getByText('Application added')).toBeVisible();
});
test('should be able see aplication fields', async () => {
await poAdminThirdPartyLogin.getThirdPartyAppByName(appName).click();
await expect(poAdminThirdPartyLogin.inputApplicationName).toBeVisible();
await expect(poAdminThirdPartyLogin.inputRedirectURI).toBeVisible();
await expect(poAdminThirdPartyLogin.inputClientId).toBeVisible();
await expect(poAdminThirdPartyLogin.inputClientSecret).toBeVisible();
await expect(poAdminThirdPartyLogin.inputAuthUrl).toBeVisible();
await expect(poAdminThirdPartyLogin.inputTokenUrl).toBeVisible();
});
test('should be able to delete an application', async ({ page }) => {
await poAdminThirdPartyLogin.deleteThirdPartyAppByName(appName);
await expect(poAdminThirdPartyLogin.getThirdPartyAppByName(appName)).not.toBeVisible();
await expect(page.getByText('Your entry has been deleted.')).toBeVisible();
});
});
test.describe('Integrations', () => {
let poAdminIntegrations: AdminIntegrations;
const messageCodeHighlightDefault =
'javascript,css,markdown,dockerfile,json,go,rust,clean,bash,plaintext,powershell,scss,shell,yaml,vim';
const incomingIntegrationName = faker.string.uuid();
test.beforeAll(async ({ api }) => {
await setSettingValueById(api, 'Message_Code_highlight', '');
});
test.beforeEach(async ({ page }) => {
poAdminIntegrations = new AdminIntegrations(page);
await poAdminIntegrations.goto();
});
test.afterAll(async ({ api }) => {
await setSettingValueById(api, 'Message_Code_highlight', messageCodeHighlightDefault);
});
test('should display the example payload correctly', async () => {
await poAdminIntegrations.btnNew.click();
await poAdminIntegrations.btnInstructions.click();
await expect(poAdminIntegrations.codeExamplePayload('Loading')).not.toBeVisible();
});
test('should be able to create new incoming integration', async () => {
await poAdminIntegrations.btnNew.click();
await poAdminIntegrations.inputName.fill(incomingIntegrationName);
await poAdminIntegrations.inputPostToChannel.fill('#general');
await poAdminIntegrations.inputPostAs.fill('rocket.cat');
await poAdminIntegrations.btnSave.click();
await expect(poAdminIntegrations.inputWebhookUrl).not.toHaveValue('Will be available here after saving.');
await poAdminIntegrations.btnBack.click();
await expect(poAdminIntegrations.getIntegrationByName(incomingIntegrationName)).toBeVisible();
});
test('should be able to delete an incoming integration', async () => {
await poAdminIntegrations.deleteIntegrationByName(incomingIntegrationName);
await expect(poAdminIntegrations.getIntegrationByName(incomingIntegrationName)).not.toBeVisible();
});
});
});

View File

@@ -0,0 +1,46 @@
import { faker } from '@faker-js/faker';
import { HomeChannel, Registration } from './page-objects';
import { setSettingValueById } from './utils/setSettingValueById';
import { expect, test } from './utils/test';
test.describe('anonymous-user', () => {
let poHomeChannel: HomeChannel;
test.beforeAll(async ({ api }) => {
await expect((await setSettingValueById(api, 'Accounts_AllowAnonymousRead', true)).status()).toBe(200);
await expect((await setSettingValueById(api, 'Accounts_AllowAnonymousWrite', true)).status()).toBe(200);
});
test.afterAll(async ({ api }) => {
await expect((await setSettingValueById(api, 'Accounts_AllowAnonymousRead', false)).status()).toBe(200);
await expect((await setSettingValueById(api, 'Accounts_AllowAnonymousWrite', false)).status()).toBe(200);
});
test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
await poHomeChannel.goto();
await poHomeChannel.navbar.openChat('general');
});
test('expect to go to the login page as anonymous user', async ({ page }) => {
await poHomeChannel.content.btnAnonymousSignIn.click();
await expect(page.locator('role=form')).toBeVisible();
});
test('expect to chat as anonymous user', async ({ page }) => {
poHomeChannel = new HomeChannel(page);
const poRegistration = new Registration(page);
await poHomeChannel.content.btnAnonymousTalk.click();
await expect(poRegistration.username).toBeVisible();
await poRegistration.username.type(faker.internet.userName());
await poRegistration.btnRegisterConfirmUsername.click();
await poHomeChannel.content.sendMessage('hello world');
});
});

View File

@@ -0,0 +1,72 @@
import { Users } from '../fixtures/userStates';
import { HomeChannel } from '../page-objects';
import { createTargetChannel } from '../utils/create-target-channel';
import { test, expect } from '../utils/test';
test.use({ storageState: Users.admin.state });
// TODO: Remove the skip on this test once a fix in the apps engine is done
// For some reason the app doesn't work properly when installed via insertApp method
test.describe.skip('app-surfaces-interaction', () => {
let poHomeChannel: HomeChannel;
let targetChannel: string;
test.beforeAll(async ({ api }) => {
targetChannel = await createTargetChannel(api);
});
test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
await poHomeChannel.gotoChannel(targetChannel);
});
test('expect to submit an success modal', async ({ page }) => {
await page.locator('role=button[name="Options"]').click();
await page.locator('[data-key="success"]').click();
await page.locator('role=button[name="success"]').click();
const updatedButton = page.locator('role=button[name="success"]');
await expect(updatedButton).not.toBeVisible();
});
test('expect to not close the modal and there is an error in the modal', async ({ page }) => {
await page.locator('role=button[name="Options"]').click();
await page.locator('[data-key="error"]').click();
await page.locator('role=button[name="error"]').click();
const updatedTitle = page.locator('role=button[name="error"]');
expect(updatedTitle).toBeDefined();
const input = page.locator('input[type="text"]');
await input.click();
await input.fill('fixed');
await page.locator('role=button[name="error"]').click();
await expect(input).not.toBeVisible();
});
test('expect to show the toaster error for modal that timeout the execution', async ({ page }) => {
await page.locator('role=button[name="Options"]').click();
await page.locator('[data-key="timeout"]').click();
await page.locator('role=button[name="timeout"]').click();
await page.locator('role=alert').waitFor();
const toaster = page.locator('role=alert');
await expect(toaster).toBeVisible();
});
test('expect change the modal and then submit the updated modal', async ({ page }) => {
await page.locator('role=button[name="Options"]').click();
await page.locator('[data-key="update"]').click();
await page.locator('role=button[name="update"]').click();
const updatedTitle = page.locator('role=button[name="title updated"]');
expect(updatedTitle).toBeDefined();
const updatedButton = page.locator('role=button[name="updated"]');
expect(updatedButton).toBeDefined();
});
});

View File

@@ -0,0 +1,36 @@
import type { Page } from '@playwright/test';
import { IS_EE } from '../config/constants';
import { Users } from '../fixtures/userStates';
import { HomeChannel } from '../page-objects';
import { expect, test } from '../utils/test';
test.use({ storageState: Users.user1.state });
test.describe.serial('Apps > ContextualBar', () => {
test.skip(!IS_EE, 'Premium Only');
let poHomeChannel: HomeChannel;
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
poHomeChannel = new HomeChannel(page);
await poHomeChannel.gotoChannel('general');
});
test.afterAll(async () => {
await page.close();
});
test('expect allow user open app contextualbar', async () => {
await poHomeChannel.content.dispatchSlashCommand('/contextualbar');
await expect(poHomeChannel.btnContextualbarClose).toBeVisible();
});
test('expect app contextualbar to be closed', async () => {
await poHomeChannel.btnContextualbarClose.click();
await expect(poHomeChannel.btnContextualbarClose).toBeHidden();
});
});

View File

@@ -0,0 +1,44 @@
import type { Page } from '@playwright/test';
import { IS_EE } from '../config/constants';
import { Users } from '../fixtures/userStates';
import { HomeChannel } from '../page-objects';
import { AppsModal } from '../page-objects/fragments/modals';
import { expect, test } from '../utils/test';
test.use({ storageState: Users.user1.state });
test.describe.serial('Apps > Modal', () => {
test.skip(!IS_EE, 'Premium Only');
let poHomeChannel: HomeChannel;
let poModal: AppsModal;
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
poHomeChannel = new HomeChannel(page);
poModal = new AppsModal(page);
await poHomeChannel.gotoChannel('general');
});
test.afterAll(async () => {
await page.close();
});
test('should allow user open app modal', async () => {
await poHomeChannel.content.dispatchSlashCommand('/modal');
await poModal.waitForDisplay();
});
test('should display validation error message in app modal', async () => {
await poModal.btnSubmit.click();
await expect(poModal.textInputErrorMessage).toBeVisible();
});
test('should not display validation error message in app modal', async () => {
await poModal.submit('something');
});
});

View File

@@ -0,0 +1,116 @@
import { IS_EE } from '../config/constants';
import { Users } from '../fixtures/userStates';
import { Marketplace } from '../page-objects';
import { expect, test } from '../utils/test';
test.use({ storageState: Users.admin.state });
test.describe.serial('Private apps upload', () => {
let poMarketplace: Marketplace;
test.beforeEach(async ({ page }) => {
poMarketplace = new Marketplace(page);
await poMarketplace.goto();
});
test.describe('Premium', () => {
test.skip(!IS_EE, 'Premium Only');
test('expect to allow admin to upload a private app in EE, which should be enabled by default', async ({ page }) => {
const fileChooserPromise = page.waitForEvent('filechooser');
await poMarketplace.btnUploadPrivateApp.click();
await expect(poMarketplace.btnInstallPrivateApp).toBeDisabled();
await poMarketplace.btnUploadPrivateAppFile.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles('./tests/e2e/fixtures/files/test-app_0.0.1.zip');
await expect(poMarketplace.btnInstallPrivateApp).toBeEnabled();
await poMarketplace.btnInstallPrivateApp.click();
await page.getByRole('button', { name: 'Agree' }).click();
await expect(poMarketplace.appStatusTag).toHaveText('Enabled');
});
test('expect to allow admin to update a enabled private app in EE, which should remain enabled', async ({ page }) => {
const fileChooserPromise = page.waitForEvent('filechooser');
await poMarketplace.btnUploadPrivateApp.click();
await expect(poMarketplace.btnInstallPrivateApp).toBeDisabled();
await poMarketplace.btnUploadPrivateAppFile.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles('./tests/e2e/fixtures/files/test-app_0.0.1.zip');
await expect(poMarketplace.btnInstallPrivateApp).toBeEnabled();
await poMarketplace.btnInstallPrivateApp.click();
await poMarketplace.btnConfirmAppUpdate.click();
await page.getByRole('button', { name: 'Agree' }).click();
await poMarketplace.goto();
await poMarketplace.lastAppRow.click();
await expect(poMarketplace.appStatusTag).toHaveText('Enabled');
});
test('expect to allow disabling a recently installed private app in EE', async () => {
await poMarketplace.lastAppRow.click();
await expect(poMarketplace.appStatusTag).toHaveText('Enabled');
await poMarketplace.appMenu.click();
await expect(poMarketplace.btnDisableApp).toBeEnabled();
await poMarketplace.btnDisableApp.click();
await poMarketplace.btnConfirmAppUpdate.click();
await expect(poMarketplace.appStatusTag).toHaveText('Disabled');
});
test('expect to allow admin to update a disabled private app in EE, which should remain disabled', async ({ page }) => {
const fileChooserPromise = page.waitForEvent('filechooser');
await poMarketplace.btnUploadPrivateApp.click();
await expect(poMarketplace.btnInstallPrivateApp).toBeDisabled();
await poMarketplace.btnUploadPrivateAppFile.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles('./tests/e2e/fixtures/files/test-app_0.0.1.zip');
await expect(poMarketplace.btnInstallPrivateApp).toBeEnabled();
await poMarketplace.btnInstallPrivateApp.click();
await poMarketplace.btnConfirmAppUpdate.click();
await page.getByRole('button', { name: 'Agree' }).click();
await poMarketplace.goto();
await poMarketplace.lastAppRow.click();
await expect(poMarketplace.appStatusTag).toHaveText('Disabled');
});
});
test.describe('Community Edition', () => {
test.skip(IS_EE, 'CE Only');
test('expect to allow admin to upload a private app in CE, but it should be disabled by default', async ({ page }) => {
const fileChooserPromise = page.waitForEvent('filechooser');
await poMarketplace.btnUploadPrivateApp.click();
await expect(poMarketplace.btnConfirmAppUploadModal).toBeEnabled();
await poMarketplace.btnConfirmAppUploadModal.click();
await expect(poMarketplace.btnInstallPrivateApp).toBeDisabled();
await poMarketplace.btnUploadPrivateAppFile.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles('./tests/e2e/fixtures/files/test-app_0.0.1.zip');
await expect(poMarketplace.btnInstallPrivateApp).toBeEnabled();
await poMarketplace.btnInstallPrivateApp.click();
await page.getByRole('button', { name: 'Agree' }).click();
await expect(poMarketplace.appStatusTag).toHaveText('Disabled');
});
test('expect not to allow enabling a recently installed private app in CE', async () => {
await poMarketplace.lastAppRow.click();
await expect(poMarketplace.appStatusTag).toHaveText('Disabled');
await poMarketplace.appMenu.click();
await expect(poMarketplace.btnEnableApp).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,288 @@
import { appUiKitRoomTest } from '../../data/apps/app-packages';
import { IS_EE } from '../config/constants';
import { Users } from '../fixtures/userStates';
import { HomeChannel } from '../page-objects';
import { getAppLogs, installLocalTestPackage, uninstallApp } from '../utils/apps';
import { expect, test } from '../utils/test';
test.use({ storageState: Users.user1.state });
test.describe.serial('Apps > UIKit interactions data', () => {
test.skip(!IS_EE, 'Premium Only');
let poHomeChannel: HomeChannel;
let appId: string;
test.beforeAll(async () => {
const result = await installLocalTestPackage(appUiKitRoomTest);
appId = result.app.id;
});
test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
await poHomeChannel.gotoChannel('general');
});
test.afterAll(async () => {
await uninstallApp(appId);
});
/**
* Finds a log entry matching a handler method and a specific debug label.
* The app logs using `this.getLogger().debug(label, value)`, creating entries with args = [label, value].
* Each handler invocation creates a log group with `method` like `app:executeBlockActionHandler`.
*/
function findLogItem(
logs: Awaited<ReturnType<typeof getAppLogs>>['logs'],
methodFragment: string,
[arg0, arg1]: [arg0: string, arg1?: string],
) {
return logs.find(
(log) =>
log.method.includes(methodFragment) && log.entries.some((entry) => arg0 === entry.args[0] && (!arg1 || arg1 === entry.args[1])),
);
}
test('should include correct data in executeBlockActionHandler when triggered in a message', async ({ api, page }) => {
const seed = Date.now().toString();
// Send a message with a button via the slash command
await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal message ${seed}`);
const interactionResponse = page.waitForResponse('**/ui.interaction/*');
// Wait for the message with the button to appear and click it
await page.getByRole('button', { name: 'Click!' }).click();
await interactionResponse;
// Fetch app logs and validate
const logsResult = await getAppLogs(api, appId);
expect(logsResult.logs).toBeDefined();
const blockActionLog = findLogItem(logsResult.logs, 'executeBlockActionHandler', ['block_action_value', seed]);
expect(blockActionLog, 'Block action handler log not found for message').toBeTruthy();
// Verify room is present (GENERAL room)
const roomEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_room');
expect(roomEntry?.args[1], 'Room id should be present for message block action').toBe('GENERAL');
// Verify user is present
const userEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_user');
expect(userEntry?.args[1], 'User should be present for message block action').toBe('user1');
// Verify triggerId is present
const triggerEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_triggerId');
expect(triggerEntry?.args[1], 'TriggerId should be present').not.toBe('no-triggerId');
// Verify actionId is correct
const actionIdEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_actionId');
expect(actionIdEntry?.args[1], 'ActionId should be msg-button').toBe('msg-button');
// Verify container type
const containerEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_container');
expect(containerEntry?.args[1], 'Container type should be message').toBe('message');
});
test('should include correct data in executeBlockActionHandler when triggered in a contextual bar surface', async ({ api, page }) => {
const seed = Date.now().toString();
// Open a contextual bar via slash command
await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal ctx ${seed}`);
// Opening a contextual bar via the Apps Engine causes a client-side URL navigation
// (e.g. /channel/general/app/{viewId}). Wait for that navigation to complete before
// using any locators, otherwise Playwright throws "Target page, context or browser
// has been closed" while the navigation is still in progress.
await page.waitForURL(/\/app\//);
const interactionResponse = page.waitForResponse('**/ui.interaction/*');
// Wait for the contextual bar to appear and click the button
const surface = page.getByRole('dialog', { name: 'UIKit Room Test Contextual Bar' });
await surface.getByRole('button', { name: 'Click!' }).click();
await interactionResponse;
// Fetch app logs and validate
const logsResult = await getAppLogs(api, appId);
expect(logsResult.logs).toBeDefined();
// Find the most recent block action log with ctx-button actionId
const blockActionLog = findLogItem(logsResult.logs, 'executeBlockActionHandler', ['block_action_value', seed]);
expect(blockActionLog, 'Block action handler log not found for contextual bar').toBeTruthy();
// Verify room is present
const roomEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_room');
expect(roomEntry?.args[1], 'Room id should be present for contextual bar block action').toBe('GENERAL');
// Verify user is present
const userEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_user');
expect(userEntry?.args[1], 'User should be present for contextual bar block action').toBe('user1');
// Verify triggerId is present
const triggerEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_triggerId');
expect(triggerEntry?.args[1], 'TriggerId should be present').not.toBe('no-triggerId');
// Close the contextual bar
await surface.getByRole('button', { name: 'Close' }).click();
});
test('should include correct data in executeBlockActionHandler when triggered in a modal surface', async ({ api, page }) => {
const seed = Date.now().toString();
// Open a modal via slash command
await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal modal ${seed}`);
const interactionResponse = page.waitForResponse('**/ui.interaction/*');
// Wait for the modal to appear and click the button
const surface = page.getByRole('dialog', { name: 'UIKit Room Test Modal' });
await surface.getByRole('button', { name: 'Click!' }).click();
await interactionResponse;
// Fetch app logs and validate
const logsResult = await getAppLogs(api, appId);
expect(logsResult.logs).toBeDefined();
// Find the most recent block action log with modal-button actionId
const blockActionLog = findLogItem(logsResult.logs, 'executeBlockActionHandler', ['block_action_value', seed]);
expect(blockActionLog, 'Block action handler log not found for modal').toBeTruthy();
// Verify user is present
const userEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_user');
expect(userEntry?.args[1], 'User should be present for modal block action').toBe('user1');
// Verify container type is view (modal)
const containerEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_container');
expect(containerEntry?.args[1], 'Container type should be view for modal').toBe('view');
await test.step('room is intentionally absent for modal block actions (modals have no room context)', async () => {
const roomEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_room');
expect(roomEntry?.args[1], 'Room entry should not be present for modal block action').toBe('no-room');
});
// Close the modal for the next test
await surface.getByRole('button', { name: 'Close' }).click();
});
test('should include correct data in executeViewSubmitHandler when triggered in a modal surface', async ({ api, page }) => {
const seed = Date.now().toString();
// Open a modal via slash command
await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal modal ${seed}`);
const interactionResponse = page.waitForResponse('**/ui.interaction/*');
// Wait for the modal and submit it
await page.getByLabel('UIKit Room Test Modal').getByRole('button', { name: 'Submit' }).click();
await interactionResponse;
// Fetch app logs and validate
const logsResult = await getAppLogs(api, appId);
expect(logsResult.logs).toBeDefined();
const viewSubmitLog = findLogItem(logsResult.logs, 'executeViewSubmitHandler', ['view_submit_id', `modal-${seed}`]);
expect(viewSubmitLog, 'View submit handler log not found for modal').toBeTruthy();
// Verify user is present
const userEntry = viewSubmitLog?.entries.find((e) => e.args[0] === 'view_submit_user');
expect(userEntry?.args[1], 'User should be present for modal view submit').toBe('user1');
// Verify triggerId is present
const triggerEntry = viewSubmitLog?.entries.find((e) => e.args[0] === 'view_submit_triggerId');
expect(triggerEntry?.args[1], 'TriggerId should be present').not.toBe('no-triggerId');
});
test('should include correct data in executeViewSubmitHandler when triggered in a contextual bar surface', async ({ api, page }) => {
const seed = Date.now().toString();
// Open a contextual bar via slash command
await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal ctx ${seed}`);
// Wait for the client-side navigation to the contextual bar URL to complete
await page.waitForURL(/\/app\//);
const interactionResponse = page.waitForResponse('**/ui.interaction/*');
// Wait for the contextual bar and submit it
await page.getByLabel('UIKit Room Test Contextual Bar').getByRole('button', { name: 'Submit' }).click();
await interactionResponse;
// Fetch app logs and validate
const logsResult = await getAppLogs(api, appId);
expect(logsResult.logs).toBeDefined();
// Find the most recent view submit log
const viewSubmitLog = findLogItem(logsResult.logs, 'executeViewSubmitHandler', ['view_submit_id', `ctx-${seed}`]);
expect(viewSubmitLog, 'View submit handler log not found for contextual bar').toBeTruthy();
// Verify room is present
const roomEntry = viewSubmitLog?.entries.find((e) => e.args[0] === 'view_submit_room');
expect(roomEntry?.args[1], 'Room id should be present for contextual bar view submit').toBe('GENERAL');
// Verify user is present
const userEntry = viewSubmitLog?.entries.find((e) => e.args[0] === 'view_submit_user');
expect(userEntry?.args[1], 'User should be present for contextual bar view submit').toBe('user1');
});
test('should include correct data in executeViewClosedHandler when triggered in a modal surface', async ({ api, page }) => {
const seed = Date.now().toString();
// Open a modal via slash command
await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal modal ${seed}`);
const interactionResponse = page.waitForResponse('**/ui.interaction/*');
// Wait for the modal and close it (via X button, not submit)
await page.getByLabel('UIKit Room Test Modal').getByRole('button', { name: 'Close' }).click();
await interactionResponse;
// Fetch app logs and validate
const logsResult = await getAppLogs(api, appId);
expect(logsResult.logs).toBeDefined();
const viewClosedLog = findLogItem(logsResult.logs, 'executeViewClosedHandler', ['view_closed_id', `modal-${seed}`]);
expect(viewClosedLog, 'View closed handler log not found for modal').toBeTruthy();
// Verify user is present
const userEntry = viewClosedLog?.entries.find((e) => e.args[0] === 'view_closed_user');
expect(userEntry?.args[1], 'User should be present for modal view closed').toBe('user1');
});
test('should include correct data in executeViewClosedHandler when triggered in a contextual bar surface', async ({ api, page }) => {
const seed = Date.now().toString();
// Open a contextual bar via slash command
await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal ctx ${seed}`);
// Wait for the client-side navigation to the contextual bar URL to complete
await page.waitForURL(/\/app\//);
const interactionResponse = page.waitForResponse('**/ui.interaction/*');
// Wait for the contextual bar to appear and close it
await page.getByLabel('UIKit Room Test Contextual Bar').getByRole('button', { name: 'Close' }).click();
await interactionResponse;
// Fetch app logs and validate
const logsResult = await getAppLogs(api, appId);
expect(logsResult.logs).toBeDefined();
// Find the most recent view closed log
const viewClosedLog = findLogItem(logsResult.logs, 'executeViewClosedHandler', ['view_closed_id', `ctx-${seed}`]);
expect(viewClosedLog, 'View closed handler log not found for contextual bar').toBeTruthy();
// Verify room is present
const roomEntry = viewClosedLog?.entries.find((e) => e.args[0] === 'view_closed_room');
expect(roomEntry?.args[1], 'Room id should be present for contextual bar view closed').toBe('GENERAL');
// Verify user is present
const userEntry = viewClosedLog?.entries.find((e) => e.args[0] === 'view_closed_user');
expect(userEntry?.args[1], 'User should be present for contextual bar view closed').toBe('user1');
});
});

View File

@@ -0,0 +1,60 @@
import { Users } from './fixtures/userStates';
import { HomeChannel } from './page-objects';
import { createTargetChannelAndReturnFullRoom, deleteChannel, sendTargetChannelMessage } from './utils';
import { preserveSettings } from './utils/preserveSettings';
import { seedTranslatedMessage } from './utils/seedTranslatedMessage';
import { setSettingValueById } from './utils/setSettingValueById';
import { expect, test } from './utils/test';
test.use({ storageState: Users.admin.state });
const ORIGINAL_MESSAGE = 'Good morning everyone';
const TRANSLATED_MESSAGE = 'Guten Morgen zusammen';
test.describe.serial('auto-translate', () => {
let poHomeChannel: HomeChannel;
let targetChannel: string;
preserveSettings(['AutoTranslate_Enabled']);
test.beforeAll(async ({ api }) => {
await setSettingValueById(api, 'AutoTranslate_Enabled', true);
const { channel } = await createTargetChannelAndReturnFullRoom(api);
targetChannel = channel.name as string;
await sendTargetChannelMessage(api, targetChannel, { msg: ORIGINAL_MESSAGE });
await seedTranslatedMessage({
rid: channel._id,
msg: ORIGINAL_MESSAGE,
author: Users.user1,
translations: { de: TRANSLATED_MESSAGE },
});
await api.post('/autotranslate.saveSettings', { roomId: channel._id, field: 'autoTranslateLanguage', value: 'de' });
});
test.afterAll(async ({ api }) => {
await deleteChannel(api, targetChannel);
});
test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
});
test('should render the translated message once the user enables automatic translation', async () => {
await poHomeChannel.gotoChannel(targetChannel);
await expect(poHomeChannel.content.lastUserMessage).toContainText(ORIGINAL_MESSAGE);
await poHomeChannel.roomToolbar.openMoreOptions();
await poHomeChannel.roomToolbar.menuItemAutoTranslate.click();
await poHomeChannel.tabs.autoTranslate.waitForDisplay();
await poHomeChannel.tabs.autoTranslate.setAutomaticTranslation(true);
await poHomeChannel.toastMessage.waitForDisplay({ type: 'success' });
await expect(poHomeChannel.content.lastUserMessage).toContainText(TRANSLATED_MESSAGE);
await expect(poHomeChannel.content.lastUserMessage).not.toContainText(ORIGINAL_MESSAGE);
});
});

View File

@@ -0,0 +1,98 @@
import { Users } from './fixtures/userStates';
import { HomeChannel } from './page-objects';
import { createTargetChannel, createTargetPrivateChannel, createDirectMessage } from './utils';
import { setSettingValueById } from './utils/setSettingValueById';
import { test, expect } from './utils/test';
test.use({ storageState: Users.admin.state });
const testAvatars = (homeChannel: HomeChannel, channel: string, url: string) => {
test('expect sidebar avatar to have provider prefix', async () => {
expect(homeChannel.sidebar.getSidebarItemByName(channel).locator('img').getAttribute('src')).toBe(url);
});
test('expect channel header avatar to have provider prefix', async () => {
await homeChannel.navbar.openChat(channel);
expect(homeChannel.content.channelHeader.locator('img').getAttribute('src')).toBe(url);
});
test('expect channel info avatar to have provider prefix', async () => {
await homeChannel.navbar.openChat(channel);
expect(homeChannel.content.channelHeader.locator('img').getAttribute('src')).toBe(url);
});
};
test.describe('avatar-settings', () => {
let poHomeChannel: HomeChannel;
test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
await poHomeChannel.goto();
});
test.describe('external avatar provider', () => {
const providerUrlPrefix = 'https://example.com/avatar/';
test.beforeAll(async ({ api }) => {
await setSettingValueById(api, 'Accounts_RoomAvatarExternalUrl', `${providerUrlPrefix}{username}`);
await setSettingValueById(api, 'Accounts_AvatarExternalUrl', `${providerUrlPrefix}{username}`);
});
test.afterAll(async ({ api }) => {
await setSettingValueById(api, 'Accounts_RoomAvatarExternalUrl', '');
await setSettingValueById(api, 'Accounts_AvatarExternalUrl', '');
});
test.describe('public channels', () => {
let channelName = '';
let avatarUrl = '';
test.beforeAll(async ({ api }) => {
channelName = await createTargetChannel(api);
avatarUrl = `${providerUrlPrefix}${channelName}`;
});
testAvatars(poHomeChannel, channelName, avatarUrl);
});
test.describe('private channels', () => {
let channelName = '';
let avatarUrl = '';
test.beforeAll(async ({ api }) => {
channelName = await createTargetPrivateChannel(api);
avatarUrl = `${providerUrlPrefix}${channelName}`;
});
testAvatars(poHomeChannel, channelName, avatarUrl);
});
test.describe('direct messages', () => {
const channelName = Users.user2.data.username;
const avatarUrl = `${providerUrlPrefix}${channelName}`;
test.beforeAll(async ({ api }) => {
await createDirectMessage(api);
// send a message as user 2
test.use({ storageState: Users.user2.state });
await poHomeChannel.navbar.openChat(Users.user1.data.username);
await poHomeChannel.content.sendMessage('hello world');
test.use({ storageState: Users.user1.state });
});
testAvatars(poHomeChannel, channelName, avatarUrl);
test('expect message avatar to have provider prefix', async () => {
await expect(poHomeChannel.content.lastUserMessage.locator('img')).toHaveAttribute('src', avatarUrl);
});
test('expect user card avatar to have provider prefix', async () => {
await poHomeChannel.content.lastUserMessage.locator('.rcx-message-header__name-container').click();
await expect(poHomeChannel.userCard.imgUserCard).toHaveAttribute('src', avatarUrl);
});
});
});
});

View File

@@ -0,0 +1,71 @@
import type { CalendarEventImportProps } from '@rocket.chat/rest-typings';
import { Users } from './fixtures/userStates';
import { HomeChannel } from './page-objects';
import { test, expect, type BaseTest } from './utils/test';
import { links } from '../../client/lib/links';
test.use({ storageState: Users.admin.state });
// TODO: Make this test run on CI - it's slow and requires an open browser, run manually for now.
test.describe.skip('Calendar', () => {
test.beforeAll(async ({ api }) => {
expect((await api.post('/settings/Calendar_BusyStatus_Enabled', { value: true })).status()).toBe(200);
});
test.afterAll(async ({ api }) => {
expect((await api.post('/settings/Calendar_BusyStatus_Enabled', { value: false })).status()).toBe(200);
});
test.describe('Status changes', () => {
test.beforeEach(async ({ page, api }) => {
expect((await api.post('/users.setStatus', { status: 'away', message: '' })).status()).toBe(200);
await new HomeChannel(page).goto();
});
test('Should change user status back to away when there are overlapping imported events', async ({ page, api }) => {
await expect(page.getByRole('button', { name: 'User menu' }).getByRole('status')).toHaveAccessibleName('away');
const event1 = await importCalendarEvent(api, { start: 1, end: 2 });
await importCalendarEvent(api, { start: 1.5, end: 2.5 });
const event3 = await importCalendarEvent(api, { start: 2.5, end: 3 });
await test.step('Should change status to busy', async () => {
await expect(page.getByRole('button', { name: 'User menu' }).getByRole('status')).toHaveAccessibleName('busy', {
timeout: event1.endTime.getTime() - Date.now(),
});
});
await test.step('Should change status to away again', async () => {
await expect(page.getByRole('button', { name: 'User menu' }).getByRole('status')).toHaveAccessibleName('away', {
timeout: event3.endTime.getTime() - Date.now() + 1000,
});
});
});
});
});
async function importCalendarEvent(api: BaseTest['api'], { now = Date.now(), start = 2, end = 4 } = {}) {
const startTime = new Date(now + 1000 * 60 * start);
const endTime = new Date(now + 1000 * 60 * end);
const apiResponse = await api.post('/calendar-events.import', {
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
subject: 'Test appointment',
description: 'Test appointment description',
meetingUrl: links.rocketChat,
busy: true,
externalId: `test-${start}-${end}-${now}`,
} satisfies CalendarEventImportProps);
expect(apiResponse.status()).toBe(200);
const { id } = await apiResponse.json();
if (typeof id !== 'string') {
throw new Error(`Expected id to be a string, but got ${typeof id}`);
}
return { startTime, endTime, now, id };
}

View File

@@ -0,0 +1,355 @@
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { IS_EE } from './config/constants';
import { Users } from './fixtures/userStates';
import { HomeChannel } from './page-objects';
import { CreateNewDiscussionModal } from './page-objects/fragments';
import { createTargetChannel } from './utils';
import { test, expect } from './utils/test';
test.use({ storageState: Users.admin.state });
test.describe.serial('channel-management', () => {
let poHomeChannel: HomeChannel;
let targetChannel: string;
let discussionName: string;
test.beforeAll(async ({ api }) => {
targetChannel = await createTargetChannel(api);
});
test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
await poHomeChannel.goto();
});
// TODO: this should be replaced by a unit test
test('should navigate on toolbar using arrow keys', async ({ page }) => {
const roomHeaderFavoriteBtn = poHomeChannel.getRoomHeaderFavoriteBtn(IS_EE);
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.content.sendMessage('hello composer');
await roomHeaderFavoriteBtn.focus();
await expect(roomHeaderFavoriteBtn).toBeFocused();
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
await expect(poHomeChannel.roomHeaderToolbar.getByRole('button', { name: 'Threads', exact: true })).toBeFocused();
});
// TODO: this should be replaced by a unit test
test('should move the focus away from toolbar using tab key', async ({ page }) => {
const roomHeaderFavoriteBtn = poHomeChannel.getRoomHeaderFavoriteBtn(IS_EE);
await poHomeChannel.navbar.openChat(targetChannel);
await roomHeaderFavoriteBtn.focus();
await expect(roomHeaderFavoriteBtn).toBeFocused();
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await expect(poHomeChannel.roomHeaderToolbar.getByRole('button', { name: 'Call' })).not.toBeFocused();
});
test('should add user1 to targetChannel', async () => {
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.roomToolbar.openMembersTab();
await poHomeChannel.tabs.members.showAllUsers();
await poHomeChannel.tabs.members.addUser('user1');
await expect(poHomeChannel.tabs.members.memberOption('user1')).toBeVisible();
await expect(poHomeChannel.content.getSystemMessageByText('added user1')).toBeVisible();
});
test('should edit topic of targetChannel', async () => {
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.roomToolbar.openRoomInfo();
await poHomeChannel.tabs.room.btnEdit.click();
await poHomeChannel.tabs.editRoom.inputTopic.fill('hello-topic-edited');
await poHomeChannel.tabs.editRoom.btnSave.click();
await poHomeChannel.toastMessage.dismissToast();
await poHomeChannel.roomToolbar.openRoomInfo();
await expect(poHomeChannel.tabs.room.root).toContainText('hello-topic-edited');
await expect(poHomeChannel.content.getSystemMessageByText('changed room topic to hello-topic-edited')).toBeVisible();
});
test('should edit announcement of targetChannel', async () => {
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.roomToolbar.openRoomInfo();
await poHomeChannel.tabs.room.btnEdit.click();
await poHomeChannel.tabs.editRoom.inputAnnouncement.fill('hello-announcement-edited');
await poHomeChannel.tabs.editRoom.btnSave.click();
await poHomeChannel.toastMessage.dismissToast();
await poHomeChannel.roomToolbar.openRoomInfo();
await expect(poHomeChannel.tabs.room.root).toContainText('hello-announcement-edited');
await expect(poHomeChannel.content.getSystemMessageByText('changed room announcement to: hello-announcement-edited')).toBeVisible();
});
test('should edit description of targetChannel', async () => {
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.roomToolbar.openRoomInfo();
await poHomeChannel.tabs.room.btnEdit.click();
await poHomeChannel.tabs.editRoom.inputDescription.fill('hello-description-edited');
await poHomeChannel.tabs.editRoom.btnSave.click();
await poHomeChannel.toastMessage.dismissToast();
await poHomeChannel.roomToolbar.openRoomInfo();
await expect(poHomeChannel.tabs.room.root).toContainText('hello-description-edited');
await expect(poHomeChannel.content.getSystemMessageByText('changed room description to: hello-description-edited')).toBeVisible();
});
test('should edit name of targetChannel', async ({ page }) => {
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.roomToolbar.openRoomInfo();
await poHomeChannel.tabs.room.btnEdit.click();
await poHomeChannel.tabs.editRoom.inputName.fill(`NAME-EDITED-${targetChannel}`);
await poHomeChannel.tabs.editRoom.btnSave.click();
targetChannel = `NAME-EDITED-${targetChannel}`;
await expect(page.getByRole('main').getByRole('heading', { name: targetChannel })).toBeVisible();
await poHomeChannel.navbar.openChat(targetChannel);
await expect(page).toHaveURL(`/channel/${targetChannel}`);
});
test.skip('should truncate the room name for small screens', async ({ page }) => {
const hugeName = faker.string.alpha(200);
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.roomToolbar.openRoomInfo();
await poHomeChannel.tabs.room.btnEdit.click();
await poHomeChannel.tabs.editRoom.inputName.fill(hugeName);
await poHomeChannel.tabs.editRoom.btnSave.click();
targetChannel = hugeName;
await page.setViewportSize({ width: 640, height: 460 });
await expect(page.getByRole('heading', { name: hugeName })).toHaveCSS('width', '407px');
});
test('should open sidebar clicking on sidebar toggler', async ({ page }) => {
await poHomeChannel.navbar.openChat(targetChannel);
await page.setViewportSize({ width: 640, height: 460 });
await page.getByRole('button', { name: 'Open sidebar' }).click();
await expect(page.getByRole('navigation', { name: 'Sidebar' })).toBeVisible();
});
test('should open room info when clicking on roomName', async ({ page }) => {
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.getBtnOpenRoomInfo(targetChannel).focus();
await expect(poHomeChannel.getBtnOpenRoomInfo(targetChannel)).toBeFocused();
await page.keyboard.press('Space');
await page.getByRole('dialog').waitFor();
await expect(page.getByRole('dialog')).toBeVisible();
});
test('should create a discussion using the message composer', async ({ page }) => {
discussionName = faker.string.uuid();
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.composer.btnMenuMoreActions.click();
await page.getByRole('menuitem', { name: 'Discussion' }).click();
const createDiscussionModal = new CreateNewDiscussionModal(page);
await createDiscussionModal.inputName.fill(discussionName);
await createDiscussionModal.create();
await expect(page.getByRole('heading', { name: discussionName })).toBeVisible();
});
test('should access targetTeam through discussion header', async ({ page }) => {
await poHomeChannel.navbar.openChat(targetChannel);
await page.getByRole('listitem', { name: discussionName }).getByRole('button', { name: 'Reply' }).click();
await page.getByRole('button', { name: `Back to ${targetChannel} channel`, exact: true }).focus();
await expect(page.getByRole('button', { name: `Back to ${targetChannel} channel`, exact: true })).toBeFocused();
await page.keyboard.press('Space');
await expect(page).toHaveURL(`/channel/${targetChannel}`);
});
test('should edit notification preferences of targetChannel', async () => {
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.roomToolbar.openMoreOptions();
await poHomeChannel.roomToolbar.menuItemNotificationsPreferences.click();
await poHomeChannel.tabs.notificationPreferences.updateAllNotificationPreferences();
await poHomeChannel.tabs.notificationPreferences.btnSave.click();
await expect(poHomeChannel.tabs.notificationPreferences.getPreferenceByDevice('Desktop')).toContainText('Mentions');
await expect(poHomeChannel.tabs.notificationPreferences.getPreferenceByDevice('Mobile')).toContainText('Mentions');
await expect(poHomeChannel.tabs.notificationPreferences.getPreferenceByDevice('Email')).toContainText('Mentions');
});
test.describe.serial('cross user tests', () => {
let user1Page: Page;
test.beforeEach(async ({ browser }) => {
user1Page = await browser.newPage({ storageState: Users.user1.state });
});
test.afterEach(async () => {
await user1Page.close();
});
test('should mute user1', async () => {
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.roomToolbar.openMembersTab();
await poHomeChannel.tabs.members.showAllUsers();
await poHomeChannel.tabs.members.muteUser('user1');
await expect(poHomeChannel.content.getSystemMessageByText('muted user1')).toBeVisible();
const user1Channel = new HomeChannel(user1Page);
await user1Channel.gotoChannel(targetChannel);
await expect(user1Channel.composer.readOnlyFooter).toBeVisible();
});
test('should unmuteUser user1', async () => {
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.roomToolbar.openMembersTab();
await poHomeChannel.tabs.members.showAllUsers();
await poHomeChannel.tabs.members.unmuteUser('user1');
await expect(poHomeChannel.content.getSystemMessageByText('unmuted user1')).toBeVisible();
const user1Channel = new HomeChannel(user1Page);
await user1Channel.gotoChannel(targetChannel);
await expect(user1Channel.composer.inputMessage).toBeVisible();
});
test('should set user1 as moderator', async () => {
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.roomToolbar.openMembersTab();
await poHomeChannel.tabs.members.showAllUsers();
await poHomeChannel.tabs.members.setUserAsModerator('user1');
await expect(poHomeChannel.content.getSystemMessageByText('set user1 as moderator')).toBeVisible();
const user1Channel = new HomeChannel(user1Page);
await user1Channel.gotoChannel(targetChannel);
await user1Channel.roomToolbar.openRoomInfo();
await expect(user1Channel.tabs.room.btnEdit).toBeVisible();
});
test('should set user1 as owner', async ({ browser }) => {
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.roomToolbar.openMembersTab();
await poHomeChannel.tabs.members.showAllUsers();
await poHomeChannel.tabs.members.setUserAsOwner('user1');
await expect(poHomeChannel.content.getSystemMessageByText('set user1 as owner')).toBeVisible();
const user1Page = await browser.newPage({ storageState: Users.user1.state });
const user1Channel = new HomeChannel(user1Page);
await user1Channel.gotoChannel(targetChannel);
await user1Channel.roomToolbar.openRoomInfo();
await user1Channel.tabs.room.btnMore.click();
await expect(user1Channel.tabs.room.optionDelete).toBeVisible();
await user1Page.close();
});
test('should ignore user1 messages', async ({ page }) => {
await poHomeChannel.navbar.openChat(targetChannel);
await poHomeChannel.roomToolbar.openMembersTab();
await poHomeChannel.tabs.members.showAllUsers();
await poHomeChannel.tabs.members.ignoreUser('user1');
await poHomeChannel.toastMessage.waitForDisplay({ type: 'success', message: 'User has been ignored' });
await poHomeChannel.tabs.members.userInfo.openMoreActions();
await expect(poHomeChannel.tabs.members.userInfo.menu.getMenuItem('Unignore')).toBeVisible();
await page.keyboard.press('Escape');
const user1Channel = new HomeChannel(user1Page);
await user1Channel.gotoChannel(targetChannel);
await user1Channel.content.sendMessage('message to check ignore');
await expect(poHomeChannel.content.lastUserMessageBody.getByRole('button', { name: 'This message was ignored' })).toBeVisible();
await user1Channel.content.openReplyInThread();
await user1Channel.content.waitForThread();
await user1Channel.content.toggleAlsoSendThreadToChannel(false);
await user1Channel.content.sendMessageInThread('thread reply from an ignored user');
await poHomeChannel.content.openReplyInThread();
await poHomeChannel.content.waitForThread();
await expect(poHomeChannel.content.threadMessageListItems).toHaveCount(2);
await expect(poHomeChannel.content.ignoredThreadMessages).toHaveCount(2);
await expect(poHomeChannel.content.threadMessageList).not.toContainText('thread reply from an ignored user');
});
test('should unignore single user1 message', async () => {
await poHomeChannel.navbar.openChat(targetChannel);
const user1Channel = new HomeChannel(user1Page);
await user1Channel.gotoChannel(targetChannel);
await user1Channel.content.sendMessage('only message to be unignored');
await poHomeChannel.navbar.openChat(targetChannel);
await expect(poHomeChannel.content.lastUserMessageBody).toContainText('This message was ignored');
await poHomeChannel.content.lastIgnoredUserMessage.click();
await expect(poHomeChannel.content.lastUserMessageBody).toContainText('only message to be unignored');
await user1Channel.content.openReplyInThread();
await user1Channel.content.waitForThread();
await user1Channel.content.toggleAlsoSendThreadToChannel(false);
await user1Channel.content.sendMessageInThread('thread reply that stays ignored');
await user1Channel.content.sendMessageInThread('thread reply to be revealed');
await poHomeChannel.content.openReplyInThread();
await poHomeChannel.content.waitForThread();
await expect(poHomeChannel.content.ignoredThreadMessages).toHaveCount(3);
await poHomeChannel.content.lastIgnoredThreadMessage.click();
await expect(poHomeChannel.content.lastUserThreadMessage).toContainText('thread reply to be revealed');
await expect(poHomeChannel.content.ignoredThreadMessages).toHaveCount(2);
});
test('should unignore user1 messages', async ({ page }) => {
const user1Channel = new HomeChannel(user1Page);
await user1Channel.gotoChannel(targetChannel);
await user1Channel.content.sendMessage('message before being unignored');
await poHomeChannel.navbar.openChat(targetChannel);
await expect(poHomeChannel.content.lastUserMessageBody).toContainText('This message was ignored');
await poHomeChannel.roomToolbar.openMembersTab();
await poHomeChannel.tabs.members.showAllUsers();
await poHomeChannel.tabs.members.unignoreUser('user1');
await poHomeChannel.tabs.members.userInfo.openMoreActions();
await expect(poHomeChannel.tabs.members.userInfo.menu.getMenuItem('Ignore')).toBeVisible();
await page.keyboard.press('Escape');
await user1Channel.content.sendMessage('message after being unignored');
await expect(poHomeChannel.content.nthMessage(-2)).toContainText('message before being unignored');
await expect(poHomeChannel.content.lastUserMessageBody).toContainText('message after being unignored');
});
test('should readOnlyChannel show join button', async () => {
const channelName = faker.string.uuid();
await poHomeChannel.navbar.createNew('Channel', channelName, { private: false, readOnly: true });
const channel = new HomeChannel(user1Page);
await channel.gotoChannel(channelName);
await expect(user1Page.getByRole('button', { name: 'Join', exact: true })).toBeVisible();
});
});
});

View File

@@ -0,0 +1,24 @@
export const BASE_URL = process.env.BASE_URL ?? 'http://localhost:3000';
export const API_PREFIX = '/api/v1';
export const BASE_API_URL = BASE_URL + API_PREFIX;
export const IS_LOCALHOST = BASE_URL.startsWith('http://localhost');
export const IS_EE = process.env.IS_EE ? !!JSON.parse(process.env.IS_EE) : false;
export const URL_MONGODB = process.env.MONGO_URL || 'mongodb://localhost:3001/meteor?retryWrites=false';
export const MOCK_SERVER_URL = process.env.MOCK_SERVER_URL || 'http://localhost:4000';
export const ADMIN_CREDENTIALS = {
email: 'rocketchat.internal.admin.test@rocket.chat',
password: 'rocketchat.internal.admin.test',
username: 'rocketchat.internal.admin.test',
} as const;
export const DEFAULT_USER_CREDENTIALS = {
password: 'password',
bcrypt: '$2b$10$LNYaqDreDE7tt9EVEeaS9uw.C3hic9hcqFfIocMBPTMxJaDCC6QWW',
} as const;

View File

@@ -0,0 +1,11 @@
import addCustomOAuth from '../fixtures/addCustomOAuth';
import injectInitialData from '../fixtures/inject-initial-data';
import { insertDefaultTestApp } from '../utils/apps';
export default async function (): Promise<void> {
await injectInitialData();
await insertDefaultTestApp();
await addCustomOAuth();
}

View File

@@ -0,0 +1,7 @@
declare namespace PlaywrightTest {
interface Matchers<R> {
hasAttribute(a: string): Promise<R>;
toBeInvalid(): Promise<R>;
toBeBusy(): Promise<R>;
}
}

View File

@@ -0,0 +1,36 @@
FROM php:8.2-apache-bookworm
# Utilities
RUN apt-get update && \
apt-get -y install apt-transport-https git curl vim --no-install-recommends && \
rm -r /var/lib/apt/lists/*
# SimpleSAMLphp
ARG SIMPLESAMLPHP_VERSION=1.19.6
RUN curl -s -L -o /tmp/simplesamlphp.tar.gz https://github.com/simplesamlphp/simplesamlphp/releases/download/v$SIMPLESAMLPHP_VERSION/simplesamlphp-$SIMPLESAMLPHP_VERSION.tar.gz && \
tar xzf /tmp/simplesamlphp.tar.gz -C /tmp && \
rm -f /tmp/simplesamlphp.tar.gz && \
mv /tmp/simplesamlphp-* /var/www/simplesamlphp && \
touch /var/www/simplesamlphp/modules/exampleauth/enable
COPY config/simplesamlphp/config.php /var/www/simplesamlphp/config
COPY config/simplesamlphp/authsources.php /var/www/simplesamlphp/config
COPY config/simplesamlphp/saml20-idp-hosted.php /var/www/simplesamlphp/metadata
COPY config/simplesamlphp/saml20-sp-remote.php /var/www/simplesamlphp/metadata
COPY config/simplesamlphp/server_crt /var/www/simplesamlphp/cert/server.crt
COPY config/simplesamlphp/server_pem /var/www/simplesamlphp/cert/server.pem
# Apache
COPY config/apache/ports.conf /etc/apache2
COPY config/apache/simplesamlphp.conf /etc/apache2/sites-available
COPY config/apache/cert_crt /etc/ssl/cert/cert.crt
COPY config/apache/private_key /etc/ssl/private/private.key
RUN echo "ServerName localhost" >> /etc/apache2/apache2.conf && \
a2enmod ssl && \
a2dissite 000-default.conf default-ssl.conf && \
a2ensite simplesamlphp.conf
# Set work dir
WORKDIR /var/www/simplesamlphp
# General setup
EXPOSE 8080 8443

View File

@@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJANdMEUvsTntJMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzMjIxWhcNNDgwNjI1MTQzMjIxWjBF
MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAyXRKD3KzV/hOqwThFRtA+eoitJpIIEmWbugPMC1G+7bFqxHmtxiuQhOw
yHrij35biiD8VpboY69Zep7n1QCfmIodh9uxdaNxFtzxjryRLzfP3MpPFkpBHCdV
HZWDP2TzIvOxWcnlLmikSnBrBM1nvhKSWjaFsjDAXMLXT0mceiDpQ0QQkDA6RAyx
JWWRJILjudBh56ukqvdz4eFWAAViZX5MUwCDxiBxtP3NIXVmODM7kDLqZ9+QcfpM
N5QvfcUjHP9584yrYiJ9N64Fy5vU2OH1RX5EsMHTtdqR4H5K6zNfVlgRSG170Mcj
ksTSbo1kcDCSuzTO82NrVkU+R78W/QIDAQABo1AwTjAdBgNVHQ4EFgQUkYMQMPqY
leTTtBHS1f7yNFmY86QwHwYDVR0jBBgwFoAUkYMQMPqYleTTtBHS1f7yNFmY86Qw
DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGZpbEWzYLoa5keg9rQDa
S2cf9rQFMNflwR7hQ6OtSXeP0JsQ6yFhRq3TgpBdbkmdealDJbyG8pWQxqwOBD/j
45jr+NsHap0HvQAg9pfq/QIqjH5osCGAHmNdHfP638FOpi0s6hAX11+nCW6ClHMO
ofn0fYXCBtM18qZvtoeB8swi6MlRXFLvvRL4tWzGugdQj+0f+ukk/GZAEitdpkuj
qFCHqfKwg9FJ4My5M8lIyB/P4+SK/ail/BoCJ/qGBXky2bob0MQuLysd35zrTA62
j5IvPp1XZj/2KnuPtqMuFhNtE5wCnOEC1WG02ZVIfs4DAxX78z59VSEaFlnstT9k
aQ==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,9 @@
Listen 8080
<IfModule ssl_module>
Listen 8443
</IfModule>
<IfModule mod_gnutls.c>
Listen 8443
</IfModule>

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDJdEoPcrNX+E6r
BOEVG0D56iK0mkggSZZu6A8wLUb7tsWrEea3GK5CE7DIeuKPfluKIPxWluhjr1l6
nufVAJ+Yih2H27F1o3EW3PGOvJEvN8/cyk8WSkEcJ1UdlYM/ZPMi87FZyeUuaKRK
cGsEzWe+EpJaNoWyMMBcwtdPSZx6IOlDRBCQMDpEDLElZZEkguO50GHnq6Sq93Ph
4VYABWJlfkxTAIPGIHG0/c0hdWY4MzuQMupn35Bx+kw3lC99xSMc/3nzjKtiIn03
rgXLm9TY4fVFfkSwwdO12pHgfkrrM19WWBFIbXvQxyOSxNJujWRwMJK7NM7zY2tW
RT5Hvxb9AgMBAAECggEAVT4KtHyxXJDqIL1gzICKvvUOmGMMD/VzXRx+iMEv3wTY
oWliuakM21LfpAUzZspty4XnoHAch0nET/l7WYr4/R+8HSed8IwnJyh4YhByUouI
PgGw81qaMGKIRotkTOfXZbu+GKMwgbGvivwEnLSZqDjNirS1X8/3JYkgeCFKv/X6
K4Z1SKoebtA4oGDGZtxNpeGJ6TibaSO3PRW+oH3dD5j5ez1k+dlRasMnuKG76jBy
naz052t52UZxAnGkbZUU84VljO5R8x1jip+M6+qILC/PsI1hb45Dx5VRXNUBGP2s
/PRVFptNGmsIlXBYMSdOo9RJAIsZ1kTty34nIqeuYQKBgQD4Nv+QoeRXjG7iAmzj
JabcA+2FiHftvjdaYjahXQ/Ma6ns/vv//tVfxJaNyCxMObjIKhidsrf/AAn03FgT
LkL0Q2tTBNL9jejzHstnCKSSu/pSgqatsABm1cokC9qsuMjweMVy+tGjSsF6YgjI
2K0heQpF0bMBSYmyQU4aTLbytQKBgQDPxdWVn7ReCscDwpchx+zffKh5JOshW44z
GYBfVbugBljj9w6fDjKyQzk0jMGmuG9rvqfZzu9MpQhjBFKxL4aXPBN7bQOY3DKc
FuGSbIbV7ldPmZhOxqLy6NLyiry6eSQGNf1Q8YCF6bsUR/rvdKDtBLrbJObgRVbu
ChT2PXRYKQKBgFSEDZMGvMReqebE4qSZTm592+Nq60MFUL2y0V0yXc3CHxL2Y4Hw
GGFKg+T08rhlsxhc1RLlJqdqMPmyCT9Gsj+PsTyMWPdC2b3mj2We2MKpxPtRR0W+
tvRM+U46xxOmu6y9wqV65+TM8IImXU1eEd1i5G+Pjn7ytjL+74Qe+PA9AoGAEasM
F5YmG10lQU+Z1HiQzwxlsy+Ngx+q/vNrNDAxLVF8253Vs3bcnsYSpkJV8Vx7tRjY
YzAyrzzVcr4aXhDhjBjCu1sw1B3de+KCOhZafPSwngc8qW5AyxE7Zv6fP+gvRQvw
R6LRwBF5JCde0mADk0Q0s4/2xhl/Y+ydjbb6HskCgYEAysuIUrDslGGsK57loxMO
FVz9SmLTZIJqkSW+l3dDHMG+BvnJFP+yf0Kr2zGbXRzOvNVWAFP2aU39RFDxbUIM
Nz7obLWrVKbQUDaU7fCbP4OBVuo9p4UM6j/PZy+3Cyps+GMTrC9wi4HbbNEWHyCW
xaNL9LNQ3B9hJG77htK1oRw=
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,23 @@
<VirtualHost *:8080>
ServerName localhost
DocumentRoot /var/www/simplesamlphp
Alias /simplesaml /var/www/simplesamlphp/www
<Directory /var/www/simplesamlphp/www>
<IfModule !mod_authz_core.c>
Require all granted
</IfModule>
</Directory>
</VirtualHost>
<VirtualHost *:8443>
ServerName localhost
DocumentRoot /var/www/simplesamlphp
SSLEngine on
SSLCertificateFile /etc/ssl/cert/cert.crt
SSLCertificateKeyFile /etc/ssl/private/private.key
Alias /simplesaml /var/www/simplesamlphp/www
<Directory /var/www/simplesamlphp/www>
<IfModule !mod_authz_core.c>
Require all granted
</IfModule>
</Directory>
</VirtualHost>

View File

@@ -0,0 +1,74 @@
<?php
$config = array(
'admin' => array(
'core:AdminPassword',
),
'example-userpass' => array(
'exampleauth:UserPass',
'samluser1:password' => array(
'uid' => array('1'),
'username' => 'samluser1',
'cn' => 'Saml User 1',
'eduPersonAffiliation' => array('group1'),
'email' => 'samluser1@example.com',
),
'samluser2:password' => array(
'uid' => array('2'),
'username' => 'samluser2',
'cn' => 'Saml User 2',
'eduPersonAffiliation' => array('group2'),
'email' => 'user_for_saml_merge@email.com',
),
'samluser3:password' => array(
'uid' => array('3'),
'username' => 'user_for_saml_merge2',
'cn' => 'Saml User 3',
'eduPersonAffiliation' => array('group2'),
'email' => 'samluser3@example.com',
),
'samluser4:password' => array(
'uid' => array('4'),
'username' => 'samluser4',
'cn' => 'Saml User 4',
'eduPersonAffiliation' => array('group4'),
'email' => 'samluser4@example.com',
'role' => 'saml-role',
),
'samluser5:password' => array(
'uid' => array('5'),
'username' => 'samluser5',
'eduPersonAffiliation' => array('group5'),
'email' => 'samluser5@example.com',
),
'samluser6:password' => array(
'uid' => array('6'),
'username' => 'samluser6',
'displayName' => 'Saml User 6 Display Name',
'eduPersonAffiliation' => array('group6'),
'email' => 'samluser6@example.com',
),
'samluser7:password' => array(
'uid' => array('7'),
'username' => 'samluser7',
'cn' => 'Saml User 7',
'eduPersonAffiliation' => array('group7'),
'email' => 'samluser7@example.com',
'channels' => array('saml-channel-1', 'saml-channel-2'),
),
'samlusernoname:password' => array(
'uid' => array('5'),
'cn' => 'Saml User No Username',
'eduPersonAffiliation' => array('group2'),
'email' => 'samlusernoname@example.com',
),
'samlusernoname2:password' => array(
'uid' => array('6'),
'cn' => 'Saml User No Username 2',
'eduPersonAffiliation' => array('group2'),
'email' => 'samlusernoname2@example.com',
),
),
);

View File

@@ -0,0 +1,112 @@
<?php
$config = array(
'baseurlpath' => 'simplesaml/',
'certdir' => 'cert/',
'loggingdir' => 'log/',
'datadir' => 'data/',
'tempdir' => '/tmp/simplesaml',
'debug' => true,
'showerrors' => true,
'errorreporting' => true,
'debug.validatexml' => false,
'auth.adminpassword' => 'secret',
'admin.protectindexpage' => false,
'admin.protectmetadata' => false,
'secretsalt' => 'defaultsecretsalt',
'technicalcontact_name' => 'Administrator',
'technicalcontact_email' => 'na@example.org',
'timezone' => null,
'logging.level' => SimpleSAML_Logger::DEBUG,
'logging.handler' => 'errorlog',
//'logging.format' => '%date{%b %d %H:%M:%S} %process %level %stat[%trackid] %msg',
'logging.facility' => defined('LOG_LOCAL5') ? constant('LOG_LOCAL5') : LOG_USER,
'logging.processname' => 'simplesamlphp',
'logging.logfile' => 'simplesamlphp.log',
'statistics.out' => array(
),
'database.dsn' => 'mysql:host=localhost;dbname=saml',
'database.username' => 'simplesamlphp',
'database.password' => 'secret',
'database.prefix' => '',
'database.persistent' => false,
'database.slaves' => array(
),
'enable.saml20-idp' => true,
'enable.shib13-idp' => true,
'enable.adfs-idp' => false,
'enable.wsfed-sp' => false,
'enable.authmemcookie' => false,
'session.duration' => 8 * (60 * 60), // 8 hours.
'session.datastore.timeout' => (4 * 60 * 60), // 4 hours
'session.state.timeout' => (60 * 60), // 1 hour
'session.cookie.name' => 'SimpleSAMLSessionIDIdp',
'session.cookie.lifetime' => 0,
'session.cookie.path' => '/',
'session.cookie.domain' => null,
'session.cookie.secure' => false,
'session.cookie.samesite' => 'Lax',
'enable.http_post' => false,
'session.phpsession.cookiename' => 'PHPSESSIDIDP',
'session.phpsession.savepath' => null,
'session.phpsession.httponly' => true,
'session.authtoken.cookiename' => 'SimpleSAMLAuthTokenIdp',
'session.rememberme.enable' => false,
'session.rememberme.checked' => false,
'session.rememberme.lifetime' => (14 * 86400),
'language.available' => array(
'en', 'no', 'nn', 'se', 'da', 'de', 'sv', 'fi', 'es', 'fr', 'it', 'nl', 'lb', 'cs',
'sl', 'lt', 'hr', 'hu', 'pl', 'pt', 'pt-br', 'tr', 'ja', 'zh', 'zh-tw', 'ru', 'et',
'he', 'id', 'sr', 'lv', 'ro', 'eu'
),
'language.rtl' => array('ar', 'dv', 'fa', 'ur', 'he'),
'language.default' => 'en',
'language.parameter.name' => 'language',
'language.parameter.setcookie' => true,
'language.cookie.name' => 'language',
'language.cookie.domain' => null,
'language.cookie.path' => '/',
'language.cookie.lifetime' => (60 * 60 * 24 * 900),
'attributes.extradictionary' => null,
'theme.use' => 'default',
'default-wsfed-idp' => 'urn:federation:pingfederate:localhost',
'idpdisco.enableremember' => true,
'idpdisco.rememberchecked' => true,
'idpdisco.validate' => true,
'idpdisco.extDiscoveryStorage' => null,
'idpdisco.layout' => 'dropdown',
'shib13.signresponse' => true,
'authproc.idp' => array(
30 => 'core:LanguageAdaptor',
45 => array(
'class' => 'core:StatisticsWithAttribute',
'attributename' => 'realm',
'type' => 'saml20-idp-SSO',
),
50 => 'core:AttributeLimit',
99 => 'core:LanguageAdaptor',
),
'authproc.sp' => array(
90 => 'core:LanguageAdaptor',
),
'metadata.sources' => array(
array('type' => 'flatfile'),
),
'store.type' => 'phpsession',
'store.sql.dsn' => 'sqlite:/path/to/sqlitedatabase.sq3',
'store.sql.username' => null,
'store.sql.password' => null,
'store.sql.prefix' => 'SimpleSAMLphp',
'memcache_store.servers' => array(
array(
array('hostname' => 'localhost'),
),
),
'memcache_store.prefix' => null,
'memcache_store.expires' => 36 * (60 * 60), // 36 hours.
'metadata.sign.enable' => false,
'metadata.sign.privatekey' => null,
'metadata.sign.privatekey_pass' => null,
'metadata.sign.certificate' => null,
'proxy' => null,
'trusted.url.domains' => array('localhost:3000'),
);

View File

@@ -0,0 +1,23 @@
<?php
$metadata['http://localhost:3000/_saml/metadata/test-sp'] = [
'entityid' => 'http://localhost:3000/_saml/metadata/test-sp',
'host' => '__DEFAULT__',
'privatekey' => 'server.pem',
'certificate' => 'server.crt',
'auth' => 'example-userpass',
'redirect.sign' => true,
/*
* Force the NameID to be the raw email attribute value in emailAddress
* format, bypassing SimpleSAMLphp's default persistent (hashed) nameID
* generation.
*/
'authproc' => [
100 => [
'class' => 'saml:AttributeNameID',
'attribute' => 'email',
'Format' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
],
],
];

View File

@@ -0,0 +1,31 @@
<?php
$metadata['http://localhost:3000/_saml/metadata/test-sp'] = array (
'entityid' => 'http://localhost:3000/_saml/metadata/test-sp',
'contacts' => array (
),
'metadata-set' => 'saml20-sp-remote',
'AssertionConsumerService' => array (
0 => array (
'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
'Location' => 'http://localhost:3000/_saml/validate/test-sp',
'index' => 1,
'isDefault' => true,
),
),
'SingleLogoutService' => array (
0 => array (
'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
'Location' => 'http://localhost:3000/_saml/logout/test-sp/',
'ResponseLocation' => 'http://localhost:3000/_saml/logout/test-sp/',
),
),
'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
// Other settings that may be useful for testing, but are not compatible with the existing e2e tests
'privatekey' => 'server.pem',
'certificate' => 'server.crt',
'redirect.sign' => true,
// 'redirect.validate' => true,
// 'validate.logout' => true,
// 'assertion.encryption' => true,
);

View File

@@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBF
MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+Cgav
Og8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+
YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc
+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyix
YFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8
jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/C
YQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkw
DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6b
lEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFs
X1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7
yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7
NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG
99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2n
aQ==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDNQIWjOA1vWHUz
SPM1FIKOE4GdH65VtWlpZ9dghH4CFYN0R7mvJj4KBq86Dxt8vJvLMV16GVh0NGCR
50QH8aMbxonDTqXSoXiMM4DDSQTKBYK7aZwftc7FG35gAfdNUdr8e7VbdaPOShuq
qotDyCQpZYzbt86ABnoaJ5okE3pUFIwxw97LcdYsGZz5Ngma/V1to7aMeEqHyl8r
DRbXZUzw/U8g7yC/g+G7+64liJ4FYqLEETLLSUePKLFgUJHXbF2HgIDjur3nxlEa
ecNQYVUTVCGBFpwkI5n1t3m32avwotpUFhMImjkRETyPKZpvl0+p7mop8mwJmKpa
CVuNSj23AgMBAAECggEABn4I/B20xxXcNzASiVZJvua9DdRHtmxTlkLznBj0x2oY
y1/Nbs3d3oFRn5uEuhBZOTcphsgwdRSHDXZsP3gUObew+d2N/zieUIj8hLDVlvJP
rU/s4U/l53Q0LiNByE9ThvL+zJLPCKJtd5uHZjB5fFm69+Q7gu8xg4xHIub+0pP5
PHanmHCDrbgNN/oqlar4FZ2MXTgekW6Amyc/koE9hIn4Baa2Ke/B/AUGY4pMRLqp
TArt+GTVeWeoFY9QACUpaHpJhGb/Piou6tlU57e42cLoki1f0+SARsBBKyXA7BB1
1fMH10KQYFA68dTYWlKzQau/K4xaqg4FKmtwF66GQQKBgQD9OpNUS7oRxMHVJaBR
TNWW+V1FXycqojekFpDijPb2X5CWV16oeWgaXp0nOHFdy9EWs3GtGpfZasaRVHsX
SHtPh4Nb8JqHdGE0/CD6t0+4Dns8Bn9cSqtdQB7R3Jn7IMXi9X/U8LDKo+A18/Jq
V8VgUngMny9YjMkQIbK8TRWkYQKBgQDPf4nxO6ju+tOHHORQty3bYDD0+OV3I0+L
0yz0uPreryBVi9nY43KakH52D7UZEwwsBjjGXD+WH8xEsmBWsGNXJu025PvzIJoz
lAEiXvMp/NmYp+tY4rDmO8RhyVocBqWHzh38m0IFOd4ByFD5nLEDrA3pDVo0aNgY
n0GwRysZFwKBgQDkCj3m6ZMUsUWEty+aR0EJhmKyODBDOnY09IVhH2S/FexVFzUN
LtfK9206hp/Awez3Ln2uT4Zzqq5K7fMzUniJdBWdVB004l8voeXpIe9OZuwfcBJ9
gFi1zypx/uFDv421BzQpBN+QfOdKbvbdQVFjnqCxbSDr80yVlGMrI5fbwQKBgG09
oRrepO7EIO8GN/GCruLK/ptKGkyhy3Q6xnVEmdb47hX7ncJA5IoZPmrblCVSUNsw
n11XHabksL8OBgg9rt8oQEThQv/aDzTOW9aDlJNragejiBTwq99aYeZ1gjo1CZq4
2jKubpCfyZC4rGDtrIfZYi1q+S2UcQhtd8DdhwQbAoGAAM4EpDA4yHB5yiek1p/o
CbqRCta/Dx6Eyo0KlNAyPuFPAshupG4NBx7mT2ASfL+2VBHoi6mHSri+BDX5ryYF
fMYvp7URYoq7w7qivRlvvEg5yoYrK13F2+Gj6xJ4jEN9m0KdM/g3mJGq0HBTIQrp
Sm75WXsflOxuTn08LbgGc4s=
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,7 @@
version: '3'
services:
testsamlidp_idp:
build: .
ports:
- "8080:8080"
- "8443:8443"

View File

@@ -0,0 +1,33 @@
import { faker } from '@faker-js/faker';
import { Users } from './fixtures/userStates';
import { HomeChannel } from './page-objects';
import { test, expect } from './utils/test';
test.use({ storageState: Users.admin.state });
test.describe.serial('create-channel', () => {
let poHomeChannel: HomeChannel;
test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
await poHomeChannel.goto();
});
test('expect create a public channel', async ({ page }) => {
const channelName = faker.string.uuid();
await poHomeChannel.navbar.createNew('Channel', channelName, { private: false });
await expect(page).toHaveURL(`/channel/${channelName}`);
});
test('expect create a private channel', async ({ page }) => {
const channelName = faker.string.uuid();
await poHomeChannel.navbar.createNew('Channel', channelName, { private: true });
await expect(page).toHaveURL(`/group/${channelName}`);
});
});

View File

@@ -0,0 +1,20 @@
import { Users } from './fixtures/userStates';
import { HomeChannel } from './page-objects';
import { test, expect } from './utils/test';
test.use({ storageState: Users.admin.state });
test.describe.serial('channel-direct-message', () => {
let poHomeChannel: HomeChannel;
test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
await poHomeChannel.goto();
});
test('expect create a direct room', async ({ page }) => {
await poHomeChannel.navbar.createNewDM('rocket.cat');
await expect(page).toHaveURL(/direct\/.*/);
});
});

View File

@@ -0,0 +1,26 @@
import { faker } from '@faker-js/faker';
import { Users } from './fixtures/userStates';
import { HomeDiscussion } from './page-objects';
import { test, expect } from './utils/test';
test.use({ storageState: Users.admin.state });
test.describe.serial('create-discussion', () => {
let poHomeDiscussion: HomeDiscussion;
test.beforeEach(async ({ page }) => {
poHomeDiscussion = new HomeDiscussion(page);
await poHomeDiscussion.goto();
});
test('expect create discussion', async ({ page }) => {
const discussionName = faker.string.uuid();
const discussionMessage = faker.animal.type();
await poHomeDiscussion.navbar.createNewDiscussion('general', discussionName, discussionMessage);
await expect(page).toHaveURL(/\/channel\/[a-z0-9]{0,17}$/i);
});
});

View File

@@ -0,0 +1,104 @@
import { DEFAULT_USER_CREDENTIALS } from './config/constants';
import { AccountProfile, Authenticated, Login } from './page-objects';
import { setSettingValueById } from './utils';
import { test, expect } from './utils/test';
import { createTestUser, type ITestUser } from './utils/user-helpers';
test.describe('Delete Own Account', () => {
let poAccountProfile: AccountProfile;
let poLogin: Login;
let poAuth: Authenticated;
let userToDelete: ITestUser;
let userWithInvalidPassword: ITestUser;
let userWithoutPermissions: ITestUser;
test.beforeAll(async ({ api }) => {
expect((await setSettingValueById(api, 'Accounts_AllowDeleteOwnAccount', true)).status()).toBe(200);
userToDelete = await createTestUser(api, { username: 'user-to-delete' });
userWithInvalidPassword = await createTestUser(api, { username: 'user-with-invalid-password' });
userWithoutPermissions = await createTestUser(api, { username: 'user-without-permissions' });
});
test.beforeEach(async ({ page }) => {
poAccountProfile = new AccountProfile(page);
poLogin = new Login(page);
poAuth = new Authenticated(page);
await poLogin.goto();
});
test.afterAll(async ({ api }) => {
expect((await setSettingValueById(api, 'Accounts_AllowDeleteOwnAccount', false)).status()).toBe(200);
await userWithInvalidPassword.delete();
await userWithoutPermissions.delete();
});
test('should not delete account when invalid password is provided', async () => {
await test.step('login with the user to delete', async () => {
await poLogin.login(userWithInvalidPassword.data.username, DEFAULT_USER_CREDENTIALS.password);
await poAuth.waitForDisplay();
});
await test.step('navigate to profile and locate Delete My Account button', async () => {
await poAccountProfile.goto();
await poAccountProfile.btnDeleteMyAccount.click();
await poAccountProfile.deleteAccountModal.waitForDisplay();
});
await test.step('enter invalid password in the confirmation field and click delete account', async () => {
await poAccountProfile.deleteAccountModal.inputPassword.fill('invalid-password');
await expect(poAccountProfile.deleteAccountModal.inputPassword).toHaveValue('invalid-password');
await poAccountProfile.deleteAccountModal.confirmDelete({ waitForDismissal: false });
});
await test.step('verify error message appears', async () => {
await expect(poAccountProfile.deleteAccountModal.inputErrorMessage).toBeVisible();
});
await test.step('verify user is still on the profile page', async () => {
await expect(poAccountProfile.profileHeading).toBeVisible();
});
});
test('should delete account when valid password is provided and permission is enabled', async () => {
await test.step('login with the user to delete', async () => {
await poLogin.login(userToDelete.data.username, DEFAULT_USER_CREDENTIALS.password);
await poAuth.waitForDisplay();
});
await test.step('navigate to profile and locate Delete My Account button', async () => {
await poAccountProfile.goto();
await poAccountProfile.btnDeleteMyAccount.click();
await poAccountProfile.deleteAccountModal.waitForDisplay();
});
await test.step('enter password in the confirmation field and click delete account', async () => {
await poAccountProfile.deleteAccountModal.inputPassword.fill(DEFAULT_USER_CREDENTIALS.password);
await expect(poAccountProfile.deleteAccountModal.inputPassword).toHaveValue(DEFAULT_USER_CREDENTIALS.password);
await poAccountProfile.deleteAccountModal.confirmDelete({ waitForDismissal: false });
});
await test.step('verify user is redirected to login page', async () => {
await poLogin.waitForDisplay();
userToDelete.markAsDeleted();
});
});
test.describe('Delete Own Account - Permission Disabled', () => {
test.beforeAll(async ({ api }) => {
const response = await api.post('/settings/Accounts_AllowDeleteOwnAccount', { value: false });
expect(response.status()).toBe(200);
});
test('should not show delete account button when permission is disabled', async () => {
await test.step('login with the user to delete', async () => {
await poLogin.login(userWithoutPermissions.data.username, DEFAULT_USER_CREDENTIALS.password);
await poAuth.waitForDisplay();
});
await test.step('navigate to profile and locate Delete My Account button', async () => {
await poAccountProfile.goto();
await expect(poAccountProfile.btnDeleteMyAccount).not.toBeVisible();
});
});
});
});

View File

@@ -0,0 +1,407 @@
import { faker } from '@faker-js/faker';
import { DEFAULT_USER_CREDENTIALS } from '../config/constants';
import { Users } from '../fixtures/userStates';
import { HomeChannel } from '../page-objects';
import { ToastMessages } from '../page-objects/fragments';
import { CreateE2EEChannel } from '../page-objects/fragments/e2ee';
import { deletePrivateRoomsByName } from '../utils';
import { preserveSettings } from '../utils/preserveSettings';
import { test, expect } from '../utils/test';
const settingsList = [
'E2E_Enable',
'E2E_Allow_Unencrypted_Messages',
'E2E_Enabled_Default_DirectRooms',
'E2E_Enabled_Default_PrivateRooms',
];
preserveSettings(settingsList);
test.describe('E2EE Encrypted Channels', () => {
const createdChannels: string[] = [];
let poHomeChannel: HomeChannel;
let toastMessages: ToastMessages;
let createE2EEChannel: CreateE2EEChannel;
test.use({ storageState: Users.userE2EE.state });
test.beforeAll(async ({ api }) => {
await api.post('/settings/E2E_Enable', { value: true });
await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true });
await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false });
await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false });
await api.post('/im.delete', { username: 'user2' });
});
test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
createE2EEChannel = new CreateE2EEChannel(page);
await poHomeChannel.goto();
});
test.afterAll(async () => {
await deletePrivateRoomsByName(
{ username: Users.userE2EE.data.username, password: DEFAULT_USER_CREDENTIALS.password },
createdChannels,
);
});
test('expect create a private channel encrypted and send an encrypted message', async ({ page }) => {
toastMessages = new ToastMessages(page);
const channelName = faker.string.uuid();
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await poHomeChannel.content.sendMessage('hello world');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await toastMessages.dismissToast();
await poHomeChannel.roomToolbar.openMoreOptions();
await poHomeChannel.roomToolbar.menuItemDisableE2EEncryption.click();
await expect(page.getByRole('dialog', { name: 'Disable encryption' })).toBeVisible();
await page.getByRole('button', { name: 'Disable encryption' }).click();
await poHomeChannel.toastMessage.dismissToast();
await page.waitForTimeout(1000);
await poHomeChannel.content.sendMessage('hello world not encrypted');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world not encrypted');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible();
await poHomeChannel.roomToolbar.openMoreOptions();
await expect(poHomeChannel.roomToolbar.menuItemEnableE2EEncryption).toBeVisible();
await poHomeChannel.roomToolbar.menuItemEnableE2EEncryption.click();
await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible();
await page.getByRole('button', { name: 'Enable encryption' }).click();
await poHomeChannel.toastMessage.dismissToast();
await page.waitForTimeout(1000);
await poHomeChannel.content.sendMessage('hello world encrypted again');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world encrypted again');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
});
test('expect create a private encrypted channel and send a encrypted thread message', async ({ page }) => {
const channelName = faker.string.uuid();
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await poHomeChannel.content.sendMessage('This is the thread main message.');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is the thread main message.');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await poHomeChannel.content.openReplyInThread();
await expect(page).toHaveURL(/.*thread/);
await expect(poHomeChannel.content.mainThreadMessageText).toContainText('This is the thread main message.');
await expect(poHomeChannel.content.mainThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible();
await poHomeChannel.content.toggleAlsoSendThreadToChannel(true);
await page.getByRole('dialog').locator('[name="msg"]').last().fill('This is an encrypted thread message also sent in channel');
await page.keyboard.press('Enter');
await expect(poHomeChannel.content.lastUserThreadMessage).toContainText('This is an encrypted thread message also sent in channel');
await expect(poHomeChannel.content.lastUserThreadMessage.locator('.rcx-icon--name-key')).toBeVisible();
await expect(poHomeChannel.content.lastThreadMessagePreview).toContainText('This is an encrypted thread message also sent in channel');
await expect(poHomeChannel.content.mainThreadMessageText).toContainText('This is the thread main message.');
await expect(poHomeChannel.content.mainThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible();
});
test('expect create a private encrypted channel and check disabled message menu actions on an encrypted message', async ({ page }) => {
const channelName = faker.string.uuid();
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await poHomeChannel.content.sendMessage('This is an encrypted message.');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await poHomeChannel.content.lastUserMessage.hover();
await expect(page.locator('role=button[name="Forward message not available on encrypted content"]')).toBeDisabled();
await poHomeChannel.content.openLastMessageMenu();
await expect(page.locator('role=menuitem[name="Reply in direct message"]')).toHaveClass(/disabled/);
await expect(page.locator('role=menuitem[name="Copy link"]')).toHaveClass(/disabled/);
});
test('expect create a private channel, encrypt it and send an encrypted message', async ({ page }) => {
const channelName = faker.string.uuid();
await poHomeChannel.navbar.createNew('Channel', channelName);
createE2EEChannel.store(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await poHomeChannel.toastMessage.waitForDisplay();
await poHomeChannel.toastMessage.dismissToast();
await poHomeChannel.roomToolbar.openMoreOptions();
// TODO(@jessicaschelly/@dougfabris): fix this flaky behavior
if (!(await poHomeChannel.roomToolbar.menuItemEnableE2EEncryption.isVisible())) {
await poHomeChannel.roomToolbar.openMoreOptions();
}
await poHomeChannel.roomToolbar.menuItemEnableE2EEncryption.click();
await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible();
await page.getByRole('button', { name: 'Enable encryption' }).click();
await page.waitForTimeout(1000);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await poHomeChannel.content.sendMessage('hello world');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
});
test('expect create a encrypted private channel and mention user', async ({ page }) => {
const channelName = faker.string.uuid();
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await poHomeChannel.content.sendMessage('hello @user1');
const userMention = page.getByRole('button', {
name: 'user1',
});
await expect(userMention).toBeVisible();
});
test('expect create a encrypted private channel, mention a channel and navigate to it', async ({ page }) => {
const channelName = faker.string.uuid();
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await poHomeChannel.content.sendMessage('Are you in the #general channel?');
const channelMention = page.getByRole('button', {
name: 'general',
});
await expect(channelMention).toBeVisible();
await channelMention.click();
await expect(page).toHaveURL(`/channel/general`);
});
test('expect create a encrypted private channel, mention a channel and user', async ({ page }) => {
const channelName = faker.string.uuid();
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await poHomeChannel.content.sendMessage('Are you in the #general channel, @user1 ?');
const channelMention = page.getByRole('button', {
name: 'general',
});
const userMention = page.getByRole('button', {
name: 'user1',
});
await expect(userMention).toBeVisible();
await expect(channelMention).toBeVisible();
});
test('expect create a private channel, send unecrypted messages, encrypt the channel and delete the last message and check the last message in the sidebar', async ({
page,
}) => {
const channelName = faker.string.uuid();
// Enable Sidebar Extended display mode
await poHomeChannel.navbar.setDisplayMode('Extended');
// Create private channel
await poHomeChannel.navbar.createNew('Channel', channelName, { private: true });
createE2EEChannel.store(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await poHomeChannel.toastMessage.waitForDisplay();
await poHomeChannel.toastMessage.dismissToast();
// Send Unencrypted Messages
await poHomeChannel.content.sendMessage('first unencrypted message');
await poHomeChannel.content.sendMessage('second unencrypted message');
// Encrypt channel
await poHomeChannel.roomToolbar.openMoreOptions();
await expect(poHomeChannel.roomToolbar.menuItemEnableE2EEncryption).toBeVisible();
await poHomeChannel.roomToolbar.menuItemEnableE2EEncryption.click();
await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible();
await page.getByRole('button', { name: 'Enable encryption' }).click();
await page.waitForTimeout(1000);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
// Send Encrypted Messages
const encriptedMessage1 = 'first ENCRYPTED message';
const encriptedMessage2 = 'second ENCRYPTED message';
await poHomeChannel.content.sendMessage(encriptedMessage1);
await poHomeChannel.content.sendMessage(encriptedMessage2);
// Delete last message
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText(encriptedMessage2);
await poHomeChannel.content.openLastMessageMenu();
// TODO(@jessicaschelly/@dougfabris): fix this flaky behavior
if (!(await page.locator('role=menuitem[name="Delete"]').isVisible())) {
await poHomeChannel.content.openLastMessageMenu();
}
await page.locator('role=menuitem[name="Delete"]').click();
await page.locator('#modal-root .rcx-button-group--align-end .rcx-button--danger').click();
// Check last message in the sidebar
const sidebarChannel = poHomeChannel.sidebar.getSidebarItemByName(channelName);
await expect(sidebarChannel).toBeVisible();
await expect(sidebarChannel.getByText(`You: ${encriptedMessage2}`, { exact: true })).not.toBeVisible();
await expect(sidebarChannel.getByText(`You: ${encriptedMessage1}`, { exact: true })).toBeVisible();
});
test('expect create a private encrypted channel and pin/star an encrypted message', async ({ page }) => {
const channelName = faker.string.uuid();
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await poHomeChannel.content.sendMessage('This message should be pinned and stared.');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This message should be pinned and stared.');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await poHomeChannel.content.openLastMessageMenu();
await page.locator('role=menuitem[name="Star"]').click();
await poHomeChannel.toastMessage.waitForDisplay();
await poHomeChannel.toastMessage.dismissToast();
await poHomeChannel.content.openLastMessageMenu();
await page.locator('role=menuitem[name="Pin"]').click();
await page.locator('#modal-root >> button:has-text("Yes, pin message")').click();
await poHomeChannel.toastMessage.waitForDisplay();
await poHomeChannel.toastMessage.dismissToast();
await poHomeChannel.roomToolbar.openMoreOptions();
await poHomeChannel.roomToolbar.menuItemPinnedMessages.click();
await expect(page.getByRole('dialog', { name: 'Pinned Messages' })).toBeVisible();
const lastPinnedMessage = page
.getByRole('dialog', { name: 'Pinned Messages' })
.locator('[role="listitem"][aria-roledescription="message"]')
.last();
await expect(lastPinnedMessage).toContainText('This message should be pinned and stared.');
await lastPinnedMessage.hover();
await lastPinnedMessage.locator('role=button[name="More"]').waitFor();
await lastPinnedMessage.locator('role=button[name="More"]').click();
await expect(page.locator('role=menuitem[name="Copy link"]')).toHaveClass(/disabled/);
await poHomeChannel.btnContextualbarClose.click();
await poHomeChannel.roomToolbar.openMoreOptions();
await poHomeChannel.roomToolbar.menuItemStarredMessages.click();
const lastStarredMessage = page
.getByRole('dialog', { name: 'Starred Messages' })
.locator('[role="listitem"][aria-roledescription="message"]')
.last();
await expect(page.getByRole('dialog', { name: 'Starred Messages' })).toBeVisible();
await expect(lastStarredMessage).toContainText('This message should be pinned and stared.');
await lastStarredMessage.hover();
await lastStarredMessage.locator('role=button[name="More"]').waitFor();
await lastStarredMessage.locator('role=button[name="More"]').click();
await expect(page.locator('role=menuitem[name="Copy link"]')).toHaveClass(/disabled/);
});
test('expect to edit encrypted message', async ({ page }) => {
const channelName = faker.string.uuid();
const originalMessage = 'This is the original encrypted message';
const editedMessage = 'This is the edited encrypted message';
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await poHomeChannel.content.sendMessage(originalMessage);
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText(originalMessage);
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await poHomeChannel.content.openLastMessageMenu();
await poHomeChannel.content.btnOptionEditMessage.click();
await poHomeChannel.composer.inputMessage.fill(editedMessage);
await page.keyboard.press('Enter');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText(editedMessage);
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
});
test('expect to edit encrypted message to include mention', async ({ page }) => {
const channelName = faker.string.uuid();
const originalMessage = 'This is the original encrypted message';
const editedMessage = 'This is the edited encrypted message with a mention to @user1 and #general';
const displayedMessage = 'This is the edited encrypted message with a mention to user1 and general';
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await poHomeChannel.content.sendMessage(originalMessage);
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText(originalMessage);
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await poHomeChannel.content.openLastMessageMenu();
await poHomeChannel.content.btnOptionEditMessage.click();
await poHomeChannel.composer.inputMessage.fill(editedMessage);
await poHomeChannel.composer.btnSend.click();
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText(displayedMessage);
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
const userMention = page.getByRole('button', {
name: 'user1',
});
await expect(userMention).toBeVisible();
const channelMention = page.getByRole('button', {
name: 'general',
});
await expect(channelMention).toBeVisible();
});
});

View File

@@ -0,0 +1,217 @@
import { faker } from '@faker-js/faker';
import { resetOwnE2EKey } from './resetOwnE2EKey';
import { setupE2EEPassword } from './setupE2EEPassword';
import { ADMIN_CREDENTIALS, BASE_URL } from '../config/constants';
import { Users } from '../fixtures/userStates';
import { EncryptedRoomPage } from '../page-objects/encrypted-room';
import { Navbar } from '../page-objects/fragments';
import { CreateE2EEChannel } from '../page-objects/fragments/e2ee';
import { FileUploadModal } from '../page-objects/fragments/modals';
import { LoginPage } from '../page-objects/login';
import { createTargetGroupAndReturnFullRoom, deletePrivateRoomsByName, deleteRoom } from '../utils';
import { preserveSettings } from '../utils/preserveSettings';
import { sendMessageFromUser } from '../utils/sendMessage';
import { test, expect } from '../utils/test';
const settingsList = ['E2E_Enable', 'E2E_Allow_Unencrypted_Messages'];
preserveSettings(settingsList);
test.describe('E2EE Encryption and Decryption - Basic Features', () => {
const createdChannels: string[] = [];
let loginPage: LoginPage;
let navbar: Navbar;
let encryptedRoomPage: EncryptedRoomPage;
let createE2EEChannel: CreateE2EEChannel;
test.use({ storageState: Users.admin.state });
test.beforeAll(async ({ api }) => {
await api.post('/settings/E2E_Enable', { value: true });
await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true });
});
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
navbar = new Navbar(page);
encryptedRoomPage = new EncryptedRoomPage(page);
createE2EEChannel = new CreateE2EEChannel(page);
await expect(await resetOwnE2EKey(ADMIN_CREDENTIALS)).toBeOK();
await loginPage.goto();
await loginPage.loginByUserState(Users.admin);
});
test.afterAll(async () => {
await deletePrivateRoomsByName(ADMIN_CREDENTIALS, createdChannels);
});
test('expect placeholder text in place of encrypted message', async ({ page }) => {
const channelName = faker.string.uuid();
const messageText = 'This is an encrypted message.';
await setupE2EEPassword(page);
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await encryptedRoomPage.waitForChannel();
await expect(encryptedRoomPage.encryptedTitle).toBeVisible();
await expect(encryptedRoomPage.encryptionNotReadyIndicator).not.toBeVisible();
await encryptedRoomPage.sendMessage(messageText);
await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible();
await expect(encryptedRoomPage.lastMessage.body).toHaveText(messageText);
// Log out
await navbar.logout();
// Login again
await loginPage.loginByUserState(Users.admin);
// Navigate to the encrypted channel WITHOUT entering the password
await navbar.openChat(channelName);
await expect(encryptedRoomPage.encryptedTitle).toBeVisible();
await expect(encryptedRoomPage.encryptionNotReadyIndicator).toBeVisible();
await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible();
await expect(encryptedRoomPage.lastMessage.body).toHaveText(
'This message is end-to-end encrypted. To view it, you must enter your encryption key in your account settings.',
);
});
test('expect placeholder text in place of encrypted file upload description', async ({ page }) => {
const fileUploadModal = new FileUploadModal(page);
const channelName = faker.string.uuid();
const fileName = faker.system.commonFileName('txt');
const fileDescription = faker.lorem.sentence();
await setupE2EEPassword(page);
// Create an encrypted channel
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(encryptedRoomPage.encryptedTitle).toBeVisible();
await expect(encryptedRoomPage.encryptionNotReadyIndicator).not.toBeVisible();
await test.step('upload the file with encryption', async () => {
// Upload a file
await encryptedRoomPage.dragAndDropTxtFile();
// Update file name and send
await encryptedRoomPage.composer.getFileByName('any_file.txt').click();
await fileUploadModal.setName(fileName);
await fileUploadModal.update();
await expect(encryptedRoomPage.composer.getFileByName(fileName)).toBeVisible();
await encryptedRoomPage.composer.inputMessage.fill(fileDescription);
await encryptedRoomPage.composer.btnSend.click();
await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible();
await expect(encryptedRoomPage.lastMessage.getFileUploadByName(fileName)).toContainText(fileName);
await expect(encryptedRoomPage.lastMessage.body).toHaveText(fileDescription);
});
await test.step('disable encryption in the room', async () => {
await encryptedRoomPage.disableEncryption();
await expect(encryptedRoomPage.encryptedTitle).not.toBeVisible();
});
await test.step('upload the file without encryption', async () => {
await encryptedRoomPage.dragAndDropTxtFile();
// Update file name and send
await encryptedRoomPage.composer.getFileByName('any_file.txt').click();
await fileUploadModal.setName(fileName);
await fileUploadModal.update();
await expect(encryptedRoomPage.composer.getFileByName(fileName)).toBeVisible();
await encryptedRoomPage.composer.inputMessage.fill(fileDescription);
await encryptedRoomPage.composer.btnSend.click();
await expect(encryptedRoomPage.lastMessage.encryptedIcon).not.toBeVisible();
await expect(encryptedRoomPage.lastMessage.getFileUploadByName(fileName)).toContainText(fileName);
await expect(encryptedRoomPage.lastMessage.body).toHaveText(fileDescription);
});
await test.step('enable encryption in the room', async () => {
await encryptedRoomPage.enableEncryption();
await expect(encryptedRoomPage.encryptedTitle).toBeVisible();
});
// Log out
await navbar.logout();
// Login again
await loginPage.loginByUserState(Users.admin);
await expect(navbar.btnCreateNew).toBeVisible();
await navbar.openChat(channelName);
await expect(encryptedRoomPage.encryptedTitle).toBeVisible();
await expect(encryptedRoomPage.lastNthMessage(1).body).toHaveText(
'This message is end-to-end encrypted. To view it, you must enter your encryption key in your account settings.',
);
await expect(encryptedRoomPage.lastNthMessage(1).encryptedIcon).toBeVisible();
await expect(encryptedRoomPage.lastMessage.encryptedIcon).not.toBeVisible();
await expect(encryptedRoomPage.lastMessage.getFileUploadByName(fileName)).toContainText(fileName);
await expect(encryptedRoomPage.lastMessage.body).toHaveText(fileDescription);
});
test.describe('E2EE Quotes', () => {
let targetRoomId: string;
let targetChannelName: string;
test.afterAll(async ({ api }) => {
await deleteRoom(api, targetRoomId);
});
test('expect to not crash and not show quote message for a message_link which is not accessible to the user', async ({
page,
request,
api,
}) => {
const encryptedRoomPage = new EncryptedRoomPage(page);
targetChannelName = faker.string.uuid();
await createE2EEChannel.createAndStore(targetChannelName, createdChannels);
await expect(page).toHaveURL(`/group/${targetChannelName}`);
await expect(encryptedRoomPage.encryptedTitle).toBeVisible();
await expect(encryptedRoomPage.encryptionNotReadyIndicator).not.toBeVisible();
await encryptedRoomPage.sendMessage('First encrypted message.');
await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible();
await expect(encryptedRoomPage.lastMessage.body).toHaveText('First encrypted message.');
// create a private group for user2
const { group: user1Channel } = await createTargetGroupAndReturnFullRoom(api, {
excludeSelf: true,
members: [Users.user2.data._id],
});
targetRoomId = user1Channel._id;
// send a message to the private group, which is not accessible to the main user
const sentMessage = (await sendMessageFromUser(request, Users.user2, targetRoomId, 'This is a test message.')).message;
const messageLink = `${BASE_URL}/group/${user1Channel.name}?msg=${sentMessage._id}`;
await encryptedRoomPage.sendMessage(`This is a message with message link - ${messageLink}`);
await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible();
await expect(encryptedRoomPage.lastMessage.body).toContainText(`This is a message with message link - ${messageLink}`);
await expect(encryptedRoomPage.lastNthMessage(1).body).toContainText('First encrypted message.');
await page.reload();
await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible();
await expect(encryptedRoomPage.lastMessage.body).toContainText(`This is a message with message link - ${messageLink}`);
await expect(encryptedRoomPage.lastNthMessage(1).body).toContainText('First encrypted message.');
});
});
});

View File

@@ -0,0 +1,216 @@
import { Users } from '../fixtures/userStates';
import { HomeChannel } from '../page-objects';
import { createTargetGroupAndReturnFullRoom } from '../utils';
import { preserveSettings } from '../utils/preserveSettings';
import { test, expect } from '../utils/test';
const settingsList = [
'E2E_Enable',
'E2E_Allow_Unencrypted_Messages',
'E2E_Enable_Encrypt_Files',
'E2E_Enabled_Default_DirectRooms',
'E2E_Enabled_Default_PrivateRooms',
];
const originalSettings = preserveSettings(settingsList);
const TEST_FILE_TXT = 'any_file.txt';
test.use({ storageState: Users.userE2EE.state });
test.describe('E2EE File Encryption', () => {
let poHomeChannel: HomeChannel;
let encryptedRoomId: string;
test.beforeAll(async ({ api }) => {
await api.post('/settings/E2E_Enable', { value: true });
await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true });
await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false });
await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false });
await api.post('/im.delete', { username: 'user2' });
});
test.afterAll(async ({ api }) => {
await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: '' });
await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' });
});
test.beforeEach(async ({ api, page }) => {
poHomeChannel = new HomeChannel(page);
const { group } = await createTargetGroupAndReturnFullRoom(api, {
extraData: {
broadcast: false,
encrypted: true,
},
members: [Users.userE2EE.data._id],
});
encryptedRoomId = group._id;
await poHomeChannel.gotoGroup(group.name as string);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await expect
.poll(
() =>
page.evaluate(async (rid) => {
// eslint-disable-next-line import-x/no-absolute-path
const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts') as typeof import('../../../client/lib/e2ee/rocketchat.e2e');
const room = await e2e.getInstanceByRoomId(rid);
return room?.getState();
}, group._id),
{ message: 'expect room encryption key to be ready before sending messages' },
)
.toBe('READY');
});
test.afterEach(async ({ api }) => {
expect((await api.post('/groups.delete', { roomId: encryptedRoomId })).status()).toBe(200);
});
test('should edit encrypted message with file', async ({ page }) => {
const updatedFileName = `edited_${TEST_FILE_TXT}`;
await test.step('send a file in channel and edit it', async () => {
await poHomeChannel.content.dragAndDropTxtFile();
await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).click();
await poHomeChannel.content.inputFileUploadName.fill(updatedFileName);
await poHomeChannel.content.btnUpdateFileUpload.click();
await expect(poHomeChannel.composer.getFileByName(updatedFileName)).toBeVisible();
await poHomeChannel.composer.inputMessage.fill('any_description');
await poHomeChannel.composer.btnSend.click();
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description');
await expect(poHomeChannel.content.getLastMessageByFileName(updatedFileName)).toContainText(updatedFileName);
});
await test.step('edit the description', async () => {
await poHomeChannel.content.openLastMessageMenu();
await poHomeChannel.content.btnOptionEditMessage.click();
await expect(poHomeChannel.composer.inputMessage).toHaveValue('any_description');
await poHomeChannel.composer.inputMessage.fill('edited any_description');
await page.keyboard.press('Enter');
await expect(poHomeChannel.content.getFileDescription).toHaveText('edited any_description');
await expect(poHomeChannel.content.lastUserMessage.getByRole('link').getByText(updatedFileName)).toBeVisible();
});
await test.step('delete the file from files list', async () => {
await poHomeChannel.roomToolbar.openMoreOptions();
await poHomeChannel.roomToolbar.menuItemFiles.click();
await poHomeChannel.tabs.files.deleteFile(updatedFileName);
await expect(poHomeChannel.tabs.files.getFileByName(updatedFileName)).toHaveCount(0);
await expect(poHomeChannel.content.lastUserMessage.getByRole('link').getByText(updatedFileName)).not.toBeVisible();
});
});
test('File with Unicode filename uploads and downloads correctly', async ({ page }) => {
const UNICODE_FILE_NAME = 'Новый текстовый документ.txt';
await test.step('upload file with Unicode filename', async () => {
await poHomeChannel.content.sendFileMessage(TEST_FILE_TXT);
await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).click();
await poHomeChannel.content.inputFileUploadName.fill(UNICODE_FILE_NAME);
await poHomeChannel.content.btnUpdateFileUpload.click();
await poHomeChannel.composer.btnSend.click();
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await expect(poHomeChannel.content.getLastMessageByFileName(UNICODE_FILE_NAME)).toBeVisible();
});
await test.step('download the file and verify the Unicode filename is preserved', async () => {
await poHomeChannel.roomToolbar.openMoreOptions();
await poHomeChannel.roomToolbar.menuItemFiles.click();
await expect(poHomeChannel.tabs.files.getFileByName(UNICODE_FILE_NAME)).toBeVisible();
const [download] = await Promise.all([
page.waitForEvent('download'),
poHomeChannel.tabs.files.getFileByName(UNICODE_FILE_NAME).click(),
]);
expect(download.suggestedFilename()).toBe(UNICODE_FILE_NAME);
});
});
test('File encryption with whitelisted and blacklisted media types', async ({ api }) => {
await test.step('send a text file in channel', async () => {
const updatedFileName = `edited_${TEST_FILE_TXT}`;
await poHomeChannel.content.dragAndDropTxtFile();
await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).click();
await poHomeChannel.content.inputFileUploadName.fill(updatedFileName);
await poHomeChannel.content.btnUpdateFileUpload.click();
await poHomeChannel.composer.btnSend.click();
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await expect(poHomeChannel.content.getFileDescription).not.toBeVisible();
await expect(poHomeChannel.content.getLastMessageByFileName(updatedFileName)).toContainText(updatedFileName);
});
await test.step('set whitelisted media type setting', async () => {
await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: 'text/plain' });
});
await test.step('send text file again with whitelist setting set', async () => {
await poHomeChannel.content.dragAndDropTxtFile();
await poHomeChannel.composer.inputMessage.fill('message 2');
await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).click();
await poHomeChannel.content.inputFileUploadName.fill('any_file2.txt');
await poHomeChannel.content.btnUpdateFileUpload.click();
await poHomeChannel.composer.btnSend.click();
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2');
await expect(poHomeChannel.content.getLastMessageByFileName('any_file2.txt')).toBeVisible();
});
await test.step('set blacklisted media type setting to not accept application/octet-stream media type', async () => {
await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' });
});
await test.step('send text file again with blacklisted setting set, file upload should fail', async () => {
await poHomeChannel.content.dragAndDropTxtFile();
const composerFiles = await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).all();
await Promise.all(composerFiles.map((file) => expect(file).toHaveAttribute('readonly')));
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
});
});
test.describe('File encryption setting disabled', () => {
test.beforeAll(async ({ api }) => {
await api.post('/settings/E2E_Enable_Encrypt_Files', { value: false });
await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' });
});
test.afterAll(async ({ api }) => {
await api.post('/settings/E2E_Enable_Encrypt_Files', { value: originalSettings.E2E_Enable_Encrypt_Files });
await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' });
});
test('Upload file without encryption in e2ee room', async () => {
await test.step('send a test encrypted message to check e2ee is working', async () => {
await poHomeChannel.content.sendMessage('This is an encrypted message.');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
});
await test.step('send a text file in channel, file should not be encrypted', async () => {
await poHomeChannel.content.dragAndDropTxtFile();
await poHomeChannel.composer.inputMessage.fill('any_description');
await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).click();
await poHomeChannel.content.inputFileUploadName.fill('any_file1.txt');
await poHomeChannel.content.btnUpdateFileUpload.click();
await poHomeChannel.composer.btnSend.click();
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible();
await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description');
await expect(poHomeChannel.content.getLastMessageByFileName('any_file1.txt')).toContainText('any_file1.txt');
});
});
});
});

View File

@@ -0,0 +1,53 @@
import type { Page } from '@playwright/test';
import { createAuxContext } from '../fixtures/createAuxContext';
import injectInitialData from '../fixtures/inject-initial-data';
import { Users } from '../fixtures/userStates';
import { AccountSecurity, HomeChannel } from '../page-objects';
import { preserveSettings } from '../utils/preserveSettings';
import { test, expect } from '../utils/test';
const settingsList = [
'E2E_Enable',
'E2E_Allow_Unencrypted_Messages',
'E2E_Enabled_Default_DirectRooms',
'E2E_Enabled_Default_PrivateRooms',
];
preserveSettings(settingsList);
test.describe('E2EE Key Reset', () => {
let anotherClientPage: Page;
test.use({ storageState: Users.userE2EE.state });
test.beforeAll(async ({ api }) => {
await api.post('/settings/E2E_Enable', { value: true });
await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true });
await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false });
await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false });
await api.post('/im.delete', { username: 'user2' });
});
test.beforeEach(async ({ browser, page }) => {
anotherClientPage = (await createAuxContext(browser, Users.userE2EE)).page;
const poHomeChannel = new HomeChannel(page);
await poHomeChannel.goto();
});
test.afterEach(async () => {
await anotherClientPage.close();
await injectInitialData();
});
test('expect force logout on e2e keys reset', async ({ page }) => {
const poAccountSecurity = new AccountSecurity(page);
await poAccountSecurity.goto();
await poAccountSecurity.securityE2EEncryptionSection.click();
await poAccountSecurity.securityE2EEncryptionResetKeyButton.click();
await expect(page.locator('role=button[name="Login"]')).toBeVisible();
await expect(anotherClientPage.locator('role=button[name="Login"]')).toBeVisible();
});
});

View File

@@ -0,0 +1,100 @@
import { faker } from '@faker-js/faker';
import type { APIRequestContext } from '@playwright/test';
import { BASE_API_URL, DEFAULT_USER_CREDENTIALS } from '../config/constants';
import { Users } from '../fixtures/userStates';
import { HomeChannel } from '../page-objects';
import { CreateE2EEChannel } from '../page-objects/fragments/e2ee';
import { deletePrivateRoomsByName } from '../utils';
import { preserveSettings } from '../utils/preserveSettings';
import { test, expect } from '../utils/test';
const settingsList = [
'E2E_Enable',
'E2E_Allow_Unencrypted_Messages',
'E2E_Enabled_Default_DirectRooms',
'E2E_Enabled_Default_PrivateRooms',
];
preserveSettings(settingsList);
const sendEncryptedMessage = async (request: APIRequestContext, rid: string, encryptedMsg: string) => {
return request.post(`${BASE_API_URL}/chat.sendMessage`, {
headers: {
'X-Auth-Token': Users.userE2EE.data.loginToken,
'X-User-Id': Users.userE2EE.data._id,
},
data: {
message: {
rid,
msg: encryptedMsg,
t: 'e2e',
},
},
});
};
test.describe('E2EE Legacy Format', () => {
const createdChannels: string[] = [];
let poHomeChannel: HomeChannel;
let createE2EEChannel: CreateE2EEChannel;
test.use({ storageState: Users.userE2EE.state });
test.beforeAll(async ({ api }) => {
await api.post('/settings/E2E_Enable', { value: true });
await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true });
await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false });
await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false });
await api.post('/im.delete', { username: 'user2' });
});
test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
createE2EEChannel = new CreateE2EEChannel(page);
await poHomeChannel.goto();
});
test.afterAll(async () => {
await deletePrivateRoomsByName(
{ username: Users.userE2EE.data.username, password: DEFAULT_USER_CREDENTIALS.password },
createdChannels,
);
});
test('legacy expect create a private channel encrypted and send an encrypted message', async ({ page, request }) => {
const channelName = faker.string.uuid();
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(page.getByTitle('Encrypted')).toBeVisible();
// TODO: Fix this flakiness
await expect(page.getByRole('button', { name: 'Send' })).toBeVisible();
const rid = (await page.locator('[data-qa-rc-room]').getAttribute('data-qa-rc-room')) || '';
expect(rid).toBeTruthy();
const kid = '32c9e7917b78';
const encryptedKey =
'ibtLAKG9zcQ/NTp+86nVelUjewPbPNW+EC+eagVPVVlbxvWNXkgltrBQB4gDao1Fp6fHUibQB3dirJ4rzy7CViww0o4QjAwPPQMIxZ9DLJhjKnu6bkkOp6Z0/a9g/8Wf/cvP9/bp7tUt7Et4XMmJwIe5iyJZ35lsyduLc8V+YyK8sJiGf4BRagJoBr8xEBgqBWqg6Vwn3qtbbiTs65PqErbaUmSM3Hn6tfkcS6ukLG/DbptW1B9U66IX3fQesj50zWZiJyvxOoxDeHRH9UEStyv9SP8nrFjEKM3TDiakBeDxja6LoN8l3CjP9K/5eg25YqANZAQjlwaCaeTTHndTgQ==';
const encryptedMessage =
'3JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s=';
await page.evaluate(
async ({ rid, kid, encryptedKey }) => {
// eslint-disable-next-line import-x/no-absolute-path
const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts') as typeof import('../../../client/lib/e2ee/rocketchat.e2e');
const room = await e2e.getInstanceByRoomId(rid);
await room?.importGroupKey(kid + encryptedKey);
},
{ rid, kid, encryptedKey },
);
await sendEncryptedMessage(request, rid, kid + encryptedMessage);
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('world');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
});
});

View File

@@ -0,0 +1,350 @@
import { faker } from '@faker-js/faker';
import { resetOwnE2EKey } from './resetOwnE2EKey';
import { ADMIN_CREDENTIALS } from '../config/constants';
import injectInitialData from '../fixtures/inject-initial-data';
import { Users, storeState, restoreState } from '../fixtures/userStates';
import { AccountSecurity, HomeChannel } from '../page-objects';
import { setupE2EEPassword } from './setupE2EEPassword';
import { Authenticated } from '../page-objects/auth';
import { Navbar } from '../page-objects/fragments';
import { CreateE2EEChannel, E2EEKeyDecodeFailureBanner, EnterE2EEPasswordBanner } from '../page-objects/fragments/e2ee';
import { EnterE2EEPasswordModal, ResetE2EEPasswordModal } from '../page-objects/fragments/modals';
import { LoginPage } from '../page-objects/login';
import { deletePrivateRoomsByName } from '../utils';
import { preserveSettings } from '../utils/preserveSettings';
import { test, expect } from '../utils/test';
const settingsList = [
'E2E_Enable',
'E2E_Allow_Unencrypted_Messages',
'E2E_Enabled_Default_DirectRooms',
'E2E_Enabled_Default_PrivateRooms',
];
const originalSettings = preserveSettings(settingsList);
test.describe('E2EE Passphrase Management - Initial Setup', () => {
test.use({ storageState: Users.admin.state });
test.beforeAll(async ({ api }) => {
await api.post('/settings/E2E_Enable', { value: true });
await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true });
await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false });
await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false });
});
test.afterAll(async ({ api }) => {
await api.post('/settings/E2E_Enable', { value: originalSettings.E2E_Enable });
await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: originalSettings.E2E_Allow_Unencrypted_Messages });
await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: originalSettings.E2E_Enabled_Default_DirectRooms });
await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: originalSettings.E2E_Enabled_Default_PrivateRooms });
});
test.describe('Generate', () => {
test.beforeEach(async ({ page }) => {
const loginPage = new LoginPage(page);
await expect(await resetOwnE2EKey(ADMIN_CREDENTIALS)).toBeOK();
await loginPage.goto();
await loginPage.loginByUserState(Users.admin);
});
test('expect the randomly generated password to work', async ({ page }) => {
const loginPage = new LoginPage(page);
const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page);
const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page);
const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page);
const navbar = new Navbar(page);
const password = await setupE2EEPassword(page);
// Log out
await navbar.logout();
// Login again
await loginPage.loginByUserState(Users.admin);
// Enter the saved password
await enterE2EEPasswordBanner.click();
await enterE2EEPasswordModal.enterPassword(password);
// No error banner
await e2EEKeyDecodeFailureBanner.expectToNotBeVisible();
});
test('expect to manually reset the password', async ({ page }) => {
const accountSecurityPage = new AccountSecurity(page);
const loginPage = new LoginPage(page);
// Reset the E2EE key to start the flow from the beginning
await accountSecurityPage.goto();
await accountSecurityPage.resetE2EEPassword();
await loginPage.loginByUserState(Users.admin);
});
test('should reset e2e password from the modal', async ({ page }) => {
const navbar = new Navbar(page);
const loginPage = new LoginPage(page);
const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page);
const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page);
const resetE2EEPasswordModal = new ResetE2EEPasswordModal(page);
await setupE2EEPassword(page);
// Logout
await navbar.logout();
// Login again
await loginPage.loginByUserState(Users.admin);
// Reset E2EE password
await enterE2EEPasswordBanner.click();
await enterE2EEPasswordModal.forgotPassword();
await resetE2EEPasswordModal.confirmReset();
// restore login
await loginPage.loginByUserState(Users.admin);
});
test('expect to manually set a new password', async ({ page }) => {
const accountSecurityPage = new AccountSecurity(page);
const loginPage = new LoginPage(page);
const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page);
const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page);
const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page);
const navbar = new Navbar(page);
const newPassword = faker.internet.password({
length: 30,
prefix:
faker.string.alpha({ casing: 'lower' }) +
faker.string.alpha({ casing: 'upper' }) +
faker.string.numeric() +
faker.string.symbol(),
});
await setupE2EEPassword(page);
// Set a new password
await accountSecurityPage.goto();
await accountSecurityPage.setE2EEPassword(newPassword);
// Log out
await navbar.logout();
// Login again
await loginPage.loginByUserState(Users.admin);
// Enter the saved password
await enterE2EEPasswordBanner.click();
await enterE2EEPasswordModal.enterPassword(newPassword);
// No error banner
await e2EEKeyDecodeFailureBanner.expectToNotBeVisible();
});
});
test.describe('Recovery', () => {
test.use({ storageState: Users.userE2EE.state });
test('expect to recover the keys using the recovery key', async ({ page }) => {
await test.step('Recover the keys', async () => {
await page.goto('/home');
await injectInitialData();
await restoreState(page, Users.userE2EE);
const navbar = new Navbar(page);
await navbar.logout();
const loginPage = new LoginPage(page);
await loginPage.loginByUserState(Users.userE2EE, { except: ['private_key', 'public_key'] });
const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page);
const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page);
const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page);
await enterE2EEPasswordBanner.click();
await enterE2EEPasswordModal.enterPassword('minus mobile dexter forest elvis');
await e2EEKeyDecodeFailureBanner.expectToNotBeVisible();
});
});
});
});
test.use({ storageState: Users.admin.state });
const roomSetupSettingsList = ['E2E_Enable', 'E2E_Allow_Unencrypted_Messages'];
test.describe.serial('E2EE Passphrase Management - Room Setup States', () => {
const createdChannels: string[] = [];
let poAccountSecurity: AccountSecurity;
let poHomeChannel: HomeChannel;
let createE2EEChannel: CreateE2EEChannel;
let e2eePassword: string;
preserveSettings(roomSetupSettingsList);
test.beforeEach(async ({ page }) => {
poAccountSecurity = new AccountSecurity(page);
poHomeChannel = new HomeChannel(page);
createE2EEChannel = new CreateE2EEChannel(page);
});
test.beforeAll(async ({ api }) => {
await api.post('/settings/E2E_Enable', { value: true });
await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false });
});
test.afterEach(async ({ api }) => {
await api.recreateContext();
});
test.afterAll(async () => {
await deletePrivateRoomsByName(ADMIN_CREDENTIALS, createdChannels);
});
test('expect save password state on encrypted room', async ({ page }) => {
await poAccountSecurity.goto();
await poAccountSecurity.securityE2EEncryptionSection.click();
await poAccountSecurity.securityE2EEncryptionResetKeyButton.click();
await page.locator('role=button[name="Login"]').waitFor();
await injectInitialData();
await restoreState(page, Users.admin);
await new Authenticated(page).goto();
await expect(poHomeChannel.bannerSaveEncryptionPassword).toBeVisible();
const channelName = faker.string.uuid();
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon.first()).toBeVisible();
await expect(poHomeChannel.btnRoomSaveE2EEPassword).toBeVisible();
await poHomeChannel.roomToolbar.btnDisableE2EEncryption.waitFor();
await expect(poHomeChannel.roomToolbar.btnDisableE2EEncryption).toBeVisible();
await expect(poHomeChannel.roomToolbar.btnMembers).toBeVisible();
await expect(poHomeChannel.roomToolbar.btnRoomInfo).toBeVisible();
await expect(poHomeChannel.composer.inputMessage).not.toBeVisible();
await poHomeChannel.btnRoomSaveE2EEPassword.click();
e2eePassword = (await page.evaluate(() => localStorage.getItem('e2e.randomPassword'))) || 'undefined';
await expect(poHomeChannel.dialogSaveE2EEPassword).toBeVisible();
await expect(poHomeChannel.dialogSaveE2EEPassword).toContainText(e2eePassword);
await poHomeChannel.btnSavedMyPassword.click();
await poHomeChannel.composer.inputMessage.waitFor();
await poHomeChannel.content.sendMessage('hello world');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
});
test('expect enter password state on encrypted room', async ({ page }) => {
const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page);
await poHomeChannel.goto();
// Logout to remove e2ee keys
await poHomeChannel.navbar.logout();
await injectInitialData();
await restoreState(page, Users.admin, { except: ['private_key', 'public_key'] });
const channelName = faker.string.uuid();
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon.first()).toBeVisible();
await poHomeChannel.btnRoomEnterE2EEPassword.waitFor();
await expect(poHomeChannel.btnRoomEnterE2EEPassword).toBeVisible();
await poHomeChannel.roomToolbar.btnDisableE2EEncryption.waitFor();
await expect(poHomeChannel.roomToolbar.btnDisableE2EEncryption).toBeVisible();
await expect(poHomeChannel.roomToolbar.btnMembers).toBeVisible();
await expect(poHomeChannel.roomToolbar.btnRoomInfo).toBeVisible();
await expect(poHomeChannel.composer.inputMessage).not.toBeVisible();
await poHomeChannel.btnRoomEnterE2EEPassword.click();
await enterE2EEPasswordModal.enterPassword(e2eePassword);
await expect(poHomeChannel.bannerEnterE2EEPassword).not.toBeVisible();
await poHomeChannel.composer.inputMessage.waitFor();
// For E2EE to complete init setup
await page.waitForTimeout(300);
await poHomeChannel.content.sendMessage('hello world');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await storeState(page, Users.admin);
});
test('expect waiting for room keys state', async ({ page }) => {
await poHomeChannel.goto();
const channelName = faker.string.uuid();
await createE2EEChannel.createAndStore(channelName, createdChannels);
await expect(page).toHaveURL(`/group/${channelName}`);
await expect(poHomeChannel.content.encryptedRoomHeaderIcon.first()).toBeVisible();
await poHomeChannel.content.sendMessage('hello world');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await poHomeChannel.navbar.btnUserMenu.click();
await poHomeChannel.navbar.getUserProfileMenuOption('Profile').click();
await poAccountSecurity.sidebar.linkSecurity.click();
await poAccountSecurity.securityE2EEncryptionSection.click();
await poAccountSecurity.securityE2EEncryptionResetKeyButton.click();
await page.locator('role=button[name="Login"]').waitFor();
await page.reload();
await page.locator('role=button[name="Login"]').waitFor();
await injectInitialData();
await restoreState(page, Users.admin);
await poHomeChannel.navbar.typeSearch(channelName);
await poHomeChannel.navbar.getSearchRoomByName(channelName).click();
await poHomeChannel.btnRoomSaveE2EEPassword.click();
await poHomeChannel.btnSavedMyPassword.click();
await expect(poHomeChannel.composer.inputMessage).not.toBeVisible();
await expect(page.locator('.rcx-states__title')).toContainText('Check back later');
await poHomeChannel.roomToolbar.btnDisableE2EEncryption.waitFor();
await expect(poHomeChannel.roomToolbar.btnDisableE2EEncryption).toBeVisible();
await expect(poHomeChannel.roomToolbar.btnMembers).toBeVisible();
await expect(poHomeChannel.roomToolbar.btnRoomInfo).toBeVisible();
});
});

Some files were not shown because too many files have changed in this diff Show More