diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml new file mode 100644 index 000000000000..808b8acdcbe3 --- /dev/null +++ b/.github/actions/build-docker/action.yml @@ -0,0 +1,73 @@ +name: 'Meteor Docker' + +inputs: + CR_USER: + required: true + CR_PAT: + required: true + node-version: + required: true + description: 'Node version' + type: string + platform: + required: false + description: 'Platform' + type: string + +runs: + using: composite + + steps: + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ inputs.CR_USER }} + password: ${{ inputs.CR_PAT }} + + - name: Restore build + uses: actions/download-artifact@v3 + with: + name: build + path: /tmp/build + + - name: Unpack build + shell: bash + run: | + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz + + - uses: dtinth/setup-github-actions-caching-for-turbo@v1 + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + node-version: ${{ inputs.node-version }} + cache-modules: true + install: true + + - run: yarn build + shell: bash + + - name: Build Docker images + shell: bash + run: | + args=(rocketchat) + + if [[ '${{ inputs.platform }}' = 'alpine' ]]; then + args+=($SERVICES_PUBLISH) + fi; + + docker compose -f docker-compose-ci.yml build "${args[@]}" + + - name: Publish Docker images to GitHub Container Registry + shell: bash + run: | + args=(rocketchat) + + if [[ '${{ inputs.platform }}' = 'alpine' ]]; then + args+=($SERVICES_PUBLISH) + fi; + + docker compose -f docker-compose-ci.yml push "${args[@]}" diff --git a/.github/actions/meteor-build/action.yml b/.github/actions/meteor-build/action.yml new file mode 100644 index 000000000000..21fec059c8de --- /dev/null +++ b/.github/actions/meteor-build/action.yml @@ -0,0 +1,129 @@ +name: 'Meteor Build' + +inputs: + coverage: + required: false + description: 'Enable coverage' + type: boolean + reset-meteor: + required: false + description: 'Reset Meteor' + type: boolean + node-version: + required: true + description: 'Node version' + type: string + +runs: + using: composite + + steps: + - name: Set Swap Space + uses: pierotofy/set-swap-space@master + with: + swap-size-gb: 4 + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + node-version: ${{ inputs.node-version }} + cache-modules: true + install: true + + # - name: Free disk space + # run: | + # sudo apt clean + # docker rmi $(docker image ls -aq) + # df -h + + - name: Cache vite + uses: actions/cache@v3 + with: + path: ./node_modules/.vite + key: vite-local-cache-${{ runner.OS }}-${{ hashFiles('package.json') }} + restore-keys: | + vite-local-cache-${{ runner.os }}- + + - name: Cache meteor local + uses: actions/cache@v3 + with: + path: ./apps/meteor/.meteor/local + key: meteor-local-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/versions') }} + restore-keys: | + meteor-local-cache-${{ runner.os }}- + + - name: Cache meteor + uses: actions/cache@v3 + with: + path: ~/.meteor + key: meteor-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/release') }} + restore-keys: | + meteor-cache-${{ runner.os }}- + + - name: Install Meteor + shell: bash + run: | + # Restore bin from cache + set +e + METEOR_SYMLINK_TARGET=$(readlink ~/.meteor/meteor) + METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET") + set -e + LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor + if [ -e $LAUNCHER ] + then + echo "Cached Meteor bin found, restoring it" + sudo cp "$LAUNCHER" "/usr/local/bin/meteor" + else + echo "No cached Meteor bin found." + fi + + # only install meteor if bin isn't found + command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | sed s/--progress-bar/-sL/g | /bin/sh + + - name: Versions + shell: bash + run: | + npm --versions + yarn -v + node -v + meteor --version + meteor npm --versions + meteor node -v + git version + + - uses: dtinth/setup-github-actions-caching-for-turbo@v1 + + - name: Translation check + shell: bash + run: yarn turbo run translation-check + + - name: Reset Meteor + shell: bash + if: ${{ inputs.reset-meteor == 'true' }} + working-directory: ./apps/meteor + run: meteor reset + + - name: Build Rocket.Chat From Pull Request + shell: bash + if: startsWith(github.ref, 'refs/pull/') == true + env: + METEOR_PROFILE: 1000 + BABEL_ENV: ${{ inputs.coverage == 'true' && 'coverage' || '' }} + run: yarn build:ci -- --directory /tmp/dist + + - name: Build Rocket.Chat + shell: bash + if: startsWith(github.ref, 'refs/pull/') != true + run: yarn build:ci -- --directory /tmp/dist + + - name: Prepare build + shell: bash + run: | + cd /tmp/dist + tar czf /tmp/Rocket.Chat.tar.gz bundle + + - name: Store build + uses: actions/upload-artifact@v3 + with: + name: build + path: /tmp/Rocket.Chat.tar.gz diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70ec4dcba6ed..22250705ea58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,7 +123,7 @@ jobs: run: yarn build build: - name: 📦 Meteor Build + name: 📦 Meteor Build - coverage needs: [release-versions, packages-build] runs-on: ubuntu-20.04 @@ -138,111 +138,38 @@ jobs: echo "github.event_name: ${{ github.event_name }}" cat $GITHUB_EVENT_PATH - - name: Set Swap Space - uses: pierotofy/set-swap-space@master - with: - swap-size-gb: 4 - - uses: actions/checkout@v3 - - name: Setup NodeJS - uses: ./.github/actions/setup-node + - uses: ./.github/actions/meteor-build with: node-version: ${{ needs.release-versions.outputs.node-version }} - cache-modules: true - install: true - - # - name: Free disk space - # run: | - # sudo apt clean - # docker rmi $(docker image ls -aq) - # df -h - - - name: Cache vite - uses: actions/cache@v3 - with: - path: ./node_modules/.vite - key: vite-local-cache-${{ runner.OS }}-${{ hashFiles('package.json') }} - restore-keys: | - vite-local-cache-${{ runner.os }}- - - - name: Cache meteor local - uses: actions/cache@v3 - with: - path: ./apps/meteor/.meteor/local - key: meteor-local-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/versions') }} - restore-keys: | - meteor-local-cache-${{ runner.os }}- - - - name: Cache meteor - uses: actions/cache@v3 - with: - path: ~/.meteor - key: meteor-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/release') }} - restore-keys: | - meteor-cache-${{ runner.os }}- + coverage: true - - name: Install Meteor - run: | - # Restore bin from cache - set +e - METEOR_SYMLINK_TARGET=$(readlink ~/.meteor/meteor) - METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET") - set -e - LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor - if [ -e $LAUNCHER ] - then - echo "Cached Meteor bin found, restoring it" - sudo cp "$LAUNCHER" "/usr/local/bin/meteor" - else - echo "No cached Meteor bin found." - fi - - # only install meteor if bin isn't found - command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | sed s/--progress-bar/-sL/g | /bin/sh + build-prod: + name: 📦 Meteor Build - official + needs: [tests-done, release-versions, packages-build] + if: (github.event_name == 'release' || github.ref == 'refs/heads/develop') + runs-on: ubuntu-20.04 - - name: Versions + steps: + - name: Github Info run: | - npm --versions - yarn -v - node -v - meteor --version - meteor npm --versions - meteor node -v - git version - - - uses: dtinth/setup-github-actions-caching-for-turbo@v1 - - - name: Translation check - run: yarn turbo run translation-check - - - name: Reset Meteor - if: startsWith(github.ref, 'refs/tags/') == 'true' || github.ref == 'refs/heads/develop' - working-directory: ./apps/meteor - run: meteor reset - - - name: Build Rocket.Chat From Pull Request - if: startsWith(github.ref, 'refs/pull/') == true - env: - METEOR_PROFILE: 1000 - run: yarn build:ci -- --directory /tmp/dist - - - name: Build Rocket.Chat - if: startsWith(github.ref, 'refs/pull/') != true - run: yarn build:ci -- --directory /tmp/dist + echo "GITHUB_ACTION: $GITHUB_ACTION" + echo "GITHUB_ACTOR: $GITHUB_ACTOR" + echo "GITHUB_REF: $GITHUB_REF" + echo "GITHUB_HEAD_REF: $GITHUB_HEAD_REF" + echo "GITHUB_BASE_REF: $GITHUB_BASE_REF" + echo "github.event_name: ${{ github.event_name }}" + cat $GITHUB_EVENT_PATH - - name: Prepare build - run: | - cd /tmp/dist - tar czf /tmp/Rocket.Chat.tar.gz bundle + - uses: actions/checkout@v3 - - name: Store build - uses: actions/upload-artifact@v3 + - uses: ./.github/actions/meteor-build with: - name: build - path: /tmp/Rocket.Chat.tar.gz + node-version: ${{ needs.release-versions.outputs.node-version }} + coverage: false - build-gh-docker: + build-gh-docker-coverage: name: 🚢 Build Docker Images for Testing needs: [build, release-versions] if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') @@ -262,56 +189,39 @@ jobs: steps: - uses: actions/checkout@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ secrets.CR_USER }} - password: ${{ secrets.CR_PAT }} - - - name: Restore build - uses: actions/download-artifact@v3 - with: - name: build - path: /tmp/build - - - name: Unpack build - run: | - cd /tmp/build - tar xzf Rocket.Chat.tar.gz - rm Rocket.Chat.tar.gz - - - uses: dtinth/setup-github-actions-caching-for-turbo@v1 - - - name: Setup NodeJS - uses: ./.github/actions/setup-node + - uses: ./.github/actions/build-docker with: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} node-version: ${{ needs.release-versions.outputs.node-version }} - cache-modules: true - install: true - - - run: yarn build + platform: ${{ matrix.platform }} - - name: Build Docker images - run: | - args=(rocketchat) - - if [[ '${{ matrix.platform }}' = 'alpine' ]]; then - args+=($SERVICES_PUBLISH) - fi; + build-gh-docker: + name: 🚢 Build Docker Images for Production + needs: [build-prod, release-versions] + runs-on: ubuntu-20.04 - docker compose -f docker-compose-ci.yml build "${args[@]}" + env: + RC_DOCKERFILE: ${{ matrix.platform == 'alpine' && needs.release-versions.outputs.rc-dockerfile-alpine || needs.release-versions.outputs.rc-dockerfile }} + RC_DOCKER_TAG: ${{ matrix.platform == 'alpine' && needs.release-versions.outputs.rc-docker-tag-alpine || needs.release-versions.outputs.rc-docker-tag }} + DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }} + LOWERCASE_REPOSITORY: ${{ needs.release-versions.outputs.lowercase-repo }} + SERVICES_PUBLISH: 'authorization-service account-service ddp-streamer-service presence-service stream-hub-service' - - name: Publish Docker images to GitHub Container Registry - run: | - args=(rocketchat) + strategy: + fail-fast: false + matrix: + platform: ['official', 'alpine'] - if [[ '${{ matrix.platform }}' = 'alpine' ]]; then - args+=($SERVICES_PUBLISH) - fi; + steps: + - uses: actions/checkout@v3 - docker compose -f docker-compose-ci.yml push "${args[@]}" + - uses: ./.github/actions/build-docker + with: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + node-version: ${{ needs.release-versions.outputs.node-version }} + platform: ${{ matrix.platform }} - name: Rename official Docker tag to GitHub Container Registry if: matrix.platform == 'official' @@ -342,7 +252,7 @@ jobs: test-api: name: 🔨 Test API (CE) - needs: [checks, build-gh-docker, release-versions] + needs: [checks, build-gh-docker-coverage, release-versions] uses: ./.github/workflows/ci-test-e2e.yml with: @@ -361,7 +271,7 @@ jobs: test-ui: name: 🔨 Test UI (CE) - needs: [checks, build-gh-docker, release-versions] + needs: [checks, build-gh-docker-coverage, release-versions] uses: ./.github/workflows/ci-test-e2e.yml with: @@ -387,7 +297,7 @@ jobs: test-api-ee: name: 🔨 Test API (EE) - needs: [checks, build-gh-docker, release-versions] + needs: [checks, build-gh-docker-coverage, release-versions] uses: ./.github/workflows/ci-test-e2e.yml with: @@ -409,7 +319,7 @@ jobs: test-ui-ee: name: 🔨 Test UI (EE) - needs: [checks, build-gh-docker, release-versions] + needs: [checks, build-gh-docker-coverage, release-versions] uses: ./.github/workflows/ci-test-e2e.yml with: @@ -449,7 +359,7 @@ jobs: name: 🚀 Publish build and update our registry runs-on: ubuntu-20.04 if: github.event_name == 'release' || github.ref == 'refs/heads/develop' - needs: [tests-done, release-versions] + needs: [build-gh-docker, release-versions] steps: - uses: actions/checkout@v3 diff --git a/apps/meteor/.babelrc b/apps/meteor/.babelrc index a8c20b400ca5..382b93318fab 100644 --- a/apps/meteor/.babelrc +++ b/apps/meteor/.babelrc @@ -1,9 +1,15 @@ { - "presets": [ - "@babel/preset-env", - "@babel/preset-react" - ], - "plugins": [ - "babel-plugin-istanbul" - ] + "presets": ["@babel/preset-env", "@babel/preset-react"], + "env": { + "coverage": { + "plugins": [ + [ + "istanbul", + { + "exclude": ["**/*.spec.js", "**/*.test.js"] + } + ] + ] + } + } } diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.ts b/apps/meteor/app/lib/server/functions/getFullUserData.ts index c04148c07ba8..0703b24d9210 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.ts +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -21,6 +21,7 @@ const defaultFields = { avatarETag: 1, extension: 1, federated: 1, + statusLivechat: 1, } as const; const fullFields = { diff --git a/apps/meteor/tests/data/users.helper.js b/apps/meteor/tests/data/users.helper.js index 9dac1772dcdc..92425902cb5b 100644 --- a/apps/meteor/tests/data/users.helper.js +++ b/apps/meteor/tests/data/users.helper.js @@ -77,3 +77,15 @@ export const getMe = (overrideCredential = credentials) => resolve(res.body); }); }); + +export const setUserActiveStatus = (userId, activeStatus = true) => + new Promise((resolve) => { + request + .post(api('users.setActiveStatus')) + .set(credentials) + .send({ + userId, + activeStatus, + }) + .end(resolve); + }); diff --git a/apps/meteor/tests/end-to-end/api/01-users.js b/apps/meteor/tests/end-to-end/api/01-users.js index 130f365c96c5..d99fa68a036f 100644 --- a/apps/meteor/tests/end-to-end/api/01-users.js +++ b/apps/meteor/tests/end-to-end/api/01-users.js @@ -23,7 +23,7 @@ import { imgURL } from '../../data/interactions.js'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; import { createRoom } from '../../data/rooms.helper'; import { adminEmail, preferences, password, adminUsername } from '../../data/user'; -import { createUser, login, deleteUser, getUserStatus } from '../../data/users.helper.js'; +import { createUser, login, deleteUser, getUserStatus, getUserByUsername } from '../../data/users.helper.js'; async function createChannel(userCredentials, name) { const res = await request.post(api('channels.create')).set(userCredentials).send({ @@ -3447,6 +3447,24 @@ describe('[Users]', function () { .end(done); }); }); + it('users should retain their roles when they are deactivated', async () => { + const testUser = await createUser({ roles: ['user', 'livechat-agent'] }); + + await request + .post(api('users.setActiveStatus')) + .set(credentials) + .send({ + activeStatus: false, + userId: testUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const user = await getUserByUsername(testUser.username); + expect(user).to.have.property('roles'); + expect(user.roles).to.be.an('array').of.length(2); + expect(user.roles).to.include('user', 'livechat-agent'); + }); }); describe('[/users.deactivateIdle]', () => { diff --git a/apps/meteor/tests/end-to-end/api/livechat/19-business-hours.ts b/apps/meteor/tests/end-to-end/api/livechat/19-business-hours.ts index 9085e1cd388d..0585c20bf127 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/19-business-hours.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/19-business-hours.ts @@ -22,12 +22,12 @@ import { getDepartmentById, deleteDepartment, } from '../../../data/livechat/department'; -import { createAgent, makeAgentAvailable } from '../../../data/livechat/rooms'; +import { createAgent, createManager, makeAgentAvailable } from '../../../data/livechat/rooms'; import { removeAgent } from '../../../data/livechat/users'; import { removePermissionFromAllRoles, restorePermissionToRoles, updateSetting, updateEESetting } from '../../../data/permissions.helper'; import type { IUserCredentialsHeader } from '../../../data/user'; import { password } from '../../../data/user'; -import { createUser, deleteUser, getMe, login } from '../../../data/users.helper'; +import { setUserActiveStatus, createUser, deleteUser, getMe, getUserByUsername, login } from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; describe('LIVECHAT - business hours', function () { @@ -249,7 +249,7 @@ describe('LIVECHAT - business hours', function () { }); }); - (IS_EE ? describe : describe.skip)('[EE] BH operations upon creation', () => { + (IS_EE ? describe : describe.skip)('[EE][BH] On Business Hour created', () => { let defaultBusinessHour: ILivechatBusinessHour; before(async () => { @@ -288,7 +288,7 @@ describe('LIVECHAT - business hours', function () { // and "dep2" and both these depts are connected to same BH, then in this case after // archiving "dep1", we'd still need to BH within this user's cache since he's part of // "dep2" which is linked to BH - (IS_EE ? describe : describe.skip)('[EE] BH operations post department archiving', () => { + (IS_EE ? describe : describe.skip)('[EE][BH] On Department archived', () => { let defaultBusinessHour: ILivechatBusinessHour; let customBusinessHour: ILivechatBusinessHour; let deptLinkedToCustomBH: ILivechatDepartment; @@ -434,7 +434,7 @@ describe('LIVECHAT - business hours', function () { await deleteUser(agentLinkedToDept.user); }); }); - (IS_EE ? describe : describe.skip)('[EE] BH operations post department disablement', () => { + (IS_EE ? describe : describe.skip)('[EE][BH] On Department disabled', () => { let defaultBusinessHour: ILivechatBusinessHour; let customBusinessHour: ILivechatBusinessHour; let deptLinkedToCustomBH: ILivechatDepartment; @@ -578,7 +578,7 @@ describe('LIVECHAT - business hours', function () { await deleteUser(agentLinkedToDept.user); }); }); - (IS_EE ? describe : describe.skip)('[EE] BH operations post department removal', () => { + (IS_EE ? describe : describe.skip)('[EE][BH] On Department removed', () => { let defaultBusinessHour: ILivechatBusinessHour; let customBusinessHour: ILivechatBusinessHour; let deptLinkedToCustomBH: ILivechatDepartment; @@ -702,7 +702,7 @@ describe('LIVECHAT - business hours', function () { await deleteUser(agentLinkedToDept.user); }); }); - describe('BH behavior upon new agent creation/deletion', () => { + describe('[CE][BH] On Agent created/removed', () => { let defaultBH: ILivechatBusinessHour; let agent: ILivechatAgent; let agentCredentials: IUserCredentialsHeader; @@ -782,4 +782,75 @@ describe('LIVECHAT - business hours', function () { await deleteUser(agent._id); }); }); + + describe('[CE][BH] On Agent deactivated/activated', () => { + let defaultBH: ILivechatBusinessHour; + let agent: ILivechatAgent; + + before(async () => { + await updateSetting('Livechat_enable_business_hours', true); + await updateEESetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.SINGLE); + // wait for callbacks to run + await sleep(2000); + + defaultBH = await getDefaultBusinessHour(); + await openOrCloseBusinessHour(defaultBH, true); + + agent = await createUser(); + await createAgent(agent.username); + }); + + after(async () => { + await deleteUser(agent); + await updateSetting('Livechat_enable_business_hours', false); + }); + + it('should verify if agent becomes unavailable to take chats when user is deactivated', async () => { + await setUserActiveStatus(agent._id, false); + + const latestAgent = await getUserByUsername(agent.username); + + expect(latestAgent).to.be.an('object'); + expect(latestAgent.statusLivechat).to.be.equal(ILivechatAgentStatus.NOT_AVAILABLE); + }); + + it('should verify if agent becomes available to take chats when user is activated, if business hour is active', async () => { + await openOrCloseBusinessHour(defaultBH, true); + + await setUserActiveStatus(agent._id, true); + + const latestAgent = await getUserByUsername(agent.username); + + expect(latestAgent).to.be.an('object'); + expect(latestAgent.statusLivechat).to.be.equal(ILivechatAgentStatus.AVAILABLE); + }); + it('should verify if agent becomes unavailable to take chats when user is activated, if business hour is inactive', async () => { + await openOrCloseBusinessHour(defaultBH, false); + + await setUserActiveStatus(agent._id, false); + await setUserActiveStatus(agent._id, true); + + const latestAgent = await getUserByUsername(agent.username); + + expect(latestAgent).to.be.an('object'); + expect(latestAgent.statusLivechat).to.be.equal(ILivechatAgentStatus.NOT_AVAILABLE); + }); + it('should verify if managers are not able to make deactivated agents available', async () => { + await createManager(); + + await setUserActiveStatus(agent._id, false); + + const response = await request + .post(api('livechat/agent.status')) + .set(credentials) + .send({ + status: 'available', + agentId: agent._id, + }) + .expect(400); + + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error', 'error-user-deactivated'); + }); + }); });