Compare commits

..

2 Commits

Author SHA1 Message Date
Ali BARIN
96544df7d5 refactor(role): remove transactions and tidy up logic in model (#2141)
* refactor(role): remove returning this in model methods

* refactor(role): assert altering admin in model before update and delete

* refactor(role): rename overridePermissions with updatePermissions in model

* refactor(role): remove transactions in model

* refactor(role): remove transactions in model

* refactor(role): return with permissions upon update in model

* fix(role): assert admin check on old instance in model

* refactor(role): fetch and use current role in preventAlteringAdmin
2024-10-28 14:57:33 +01:00
Ali BARIN
036db63a33 test(role): write model tests 2024-10-28 08:22:07 +00:00
107 changed files with 17933 additions and 22397 deletions

View File

@@ -5,11 +5,8 @@ BACKEND_PORT=3000
WEB_PORT=3001
echo "Configuring backend environment variables..."
cd packages/backend
rm -rf .env
echo "
PORT=$BACKEND_PORT
WEB_APP_URL=http://localhost:$WEB_PORT
@@ -24,35 +21,24 @@ WEBHOOK_SECRET_KEY=sample_webhook_secret_key
APP_SECRET_KEY=sample_app_secret_key
REDIS_HOST=redis
SERVE_WEB_APP_SEPARATELY=true" >> .env
echo "Installing backend dependencies..."
yarn
cd $CURRENT_DIR
echo "Configuring web environment variables..."
cd packages/web
rm -rf .env
echo "
PORT=$WEB_PORT
REACT_APP_BACKEND_URL=http://localhost:$BACKEND_PORT
" >> .env
echo "Installing web dependencies..."
yarn
cd $CURRENT_DIR
echo "Installing and linking dependencies..."
yarn
yarn lerna bootstrap
echo "Migrating database..."
cd packages/backend
yarn db:migrate
yarn db:seed:user
echo "Done!"
echo "Done!"

View File

@@ -1,52 +0,0 @@
name: release-tag
on:
push:
branches:
- 'main'
jobs:
release-image:
runs-on: ubuntu-latest
env:
DOCKER_ORG: groot
DOCKER_LATEST: latest
RUNNER_TOOL_CACHE: /toolcache
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v2
with: # replace it with your local IP
config-inline: |
[registry."git.send.nrw"]
http = true
insecure = true
- name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: git.send.nrw # replace it with your local IP
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v4
with:
context: ./docker
file: ./docker/Dockerfile.compose
entrypoint: ./docker/compose-entrypoint.sh
platforms: |
linux/amd64
push: true
tags: | # replace it with your local IP and tags
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}

View File

@@ -41,11 +41,8 @@ jobs:
with:
node-version: 18
- name: Install dependencies
run: yarn
working-directory: packages/backend
run: cd packages/backend && yarn
- name: Copy .env-example.test file to .env.test
run: cp .env-example.test .env.test
working-directory: packages/backend
run: cd packages/backend && cp .env-example.test .env.test
- name: Run tests
run: yarn test:coverage
working-directory: packages/backend
run: cd packages/backend && yarn test

View File

@@ -18,13 +18,11 @@ jobs:
with:
node-version: '18'
cache: 'yarn'
cache-dependency-path: packages/backend/yarn.lock
cache-dependency-path: yarn.lock
- run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- run: yarn --frozen-lockfile
working-directory: packages/backend
- run: yarn lint
working-directory: packages/backend
- run: cd packages/backend && yarn lint
- run: echo "🍏 This job's status is ${{ job.status }}."
start-backend-server:
runs-on: ubuntu-latest
@@ -37,13 +35,11 @@ jobs:
with:
node-version: '18'
cache: 'yarn'
cache-dependency-path: packages/backend/yarn.lock
cache-dependency-path: yarn.lock
- run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- run: yarn --frozen-lockfile
working-directory: packages/backend
- run: yarn start
working-directory: packages/backend
- run: yarn --frozen-lockfile && yarn lerna bootstrap
- run: cd packages/backend && yarn start
env:
ENCRYPTION_KEY: sample_encryption_key
WEBHOOK_SECRET_KEY: sample_webhook_secret_key
@@ -59,13 +55,11 @@ jobs:
with:
node-version: '18'
cache: 'yarn'
cache-dependency-path: packages/backend/yarn.lock
cache-dependency-path: yarn.lock
- run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- run: yarn --frozen-lockfile
working-directory: packages/backend
- run: yarn start:worker
working-directory: packages/backend
- run: yarn --frozen-lockfile && yarn lerna bootstrap
- run: cd packages/backend && yarn start:worker
env:
ENCRYPTION_KEY: sample_encryption_key
WEBHOOK_SECRET_KEY: sample_webhook_secret_key
@@ -81,13 +75,11 @@ jobs:
with:
node-version: '18'
cache: 'yarn'
cache-dependency-path: packages/web/yarn.lock
cache-dependency-path: yarn.lock
- run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- run: yarn --frozen-lockfile
working-directory: packages/web
- run: yarn build
working-directory: packages/web
- run: yarn --frozen-lockfile && yarn lerna bootstrap
- run: cd packages/web && yarn build
env:
CI: false
- run: echo "🍏 This job's status is ${{ job.status }}."

View File

@@ -3,13 +3,12 @@ on:
push:
branches:
- main
# TODO: Add pull request after optimizing the total excecution time of the test suite.
# pull_request:
# paths:
# - 'packages/backend/**'
# - 'packages/e2e-tests/**'
# - 'packages/web/**'
# - '!packages/backend/src/apps/**'
pull_request:
paths:
- 'packages/backend/**'
- 'packages/e2e-tests/**'
- 'packages/web/**'
- '!packages/backend/src/apps/**'
workflow_dispatch:
env:
@@ -59,21 +58,13 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install web dependencies
run: yarn
working-directory: ./packages/web
- name: Install backend dependencies
run: yarn
working-directory: ./packages/backend
- name: Install e2e-tests dependencies
run: yarn
working-directory: ./packages/e2e-tests
- name: Install dependencies
run: yarn && yarn lerna bootstrap
- name: Install Playwright Browsers
run: yarn playwright install --with-deps
working-directory: ./packages/e2e-tests
- name: Build Automatisch web
run: yarn build
working-directory: ./packages/web
run: yarn build
env:
# Keep this until we clean up warnings in build processes
CI: false

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)

View File

@@ -1,7 +1,10 @@
version: '3.9'
services:
main:
image: git.send.nrw/groot/automatisch:latest
build:
context: ./docker
dockerfile: Dockerfile.compose
entrypoint: /compose-entrypoint.sh
ports:
- '3000:3000'
depends_on:
@@ -25,7 +28,10 @@ services:
volumes:
- automatisch_storage:/automatisch/storage
worker:
image: git.send.nrw/groot/automatisch:latest
build:
context: ./docker
dockerfile: Dockerfile.compose
entrypoint: /compose-entrypoint.sh
depends_on:
- main
environment:

View File

@@ -11,12 +11,10 @@ WORKDIR /automatisch
# copy the app, note .dockerignore
COPY . /automatisch
RUN cd packages/web && yarn
RUN yarn
RUN cd packages/web && yarn build
RUN cd packages/backend && yarn --production
RUN \
rm -rf /usr/local/share/.cache/ && \
apk del build-dependencies

13
lerna.json Normal file
View File

@@ -0,0 +1,13 @@
{
"packages": [
"packages/*"
],
"version": "0.10.0",
"npmClient": "yarn",
"useWorkspaces": true,
"command": {
"add": {
"exact": true
}
}
}

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@automatisch/root",
"license": "See LICENSE file",
"private": true,
"scripts": {
"start": "lerna run --stream --parallel --scope=@*/{web,backend} dev",
"start:web": "lerna run --stream --scope=@*/web dev",
"start:backend": "lerna run --stream --scope=@*/backend dev",
"build:docs": "cd ./packages/docs && yarn install && yarn build"
},
"workspaces": {
"packages": [
"packages/*"
],
"nohoist": [
"**/babel-loader",
"**/webpack",
"**/@automatisch/web",
"**/ajv"
]
},
"devDependencies": {
"eslint": "^8.13.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"lerna": "^4.0.0",
"prettier": "^2.5.1"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -12,7 +12,6 @@
"pretest": "APP_ENV=test node ./test/setup/prepare-test-env.js",
"test": "APP_ENV=test vitest run",
"test:watch": "APP_ENV=test vitest watch",
"test:coverage": "yarn test --coverage",
"lint": "eslint .",
"db:create": "node ./bin/database/create.js",
"db:seed:user": "node ./bin/database/seed-user.js",
@@ -24,7 +23,6 @@
"dependencies": {
"@bull-board/express": "^3.10.1",
"@casl/ability": "^6.5.0",
"@faker-js/faker": "^9.2.0",
"@node-saml/passport-saml": "^4.0.4",
"@rudderstack/rudder-sdk-node": "^1.1.2",
"@sentry/node": "^7.42.0",
@@ -38,9 +36,6 @@
"crypto-js": "^4.1.1",
"debug": "~2.6.9",
"dotenv": "^10.0.0",
"eslint": "^8.13.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"express": "~4.18.2",
"express-async-errors": "^3.1.1",
"express-basic-auth": "^1.2.1",
@@ -66,7 +61,6 @@
"pg": "^8.7.1",
"php-serialize": "^4.0.2",
"pluralize": "^8.0.0",
"prettier": "^2.5.1",
"raw-body": "^2.5.2",
"showdown": "^2.1.0",
"uuid": "^9.0.1",
@@ -98,11 +92,10 @@
"url": "https://github.com/automatisch/automatisch/issues"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.1.5",
"node-gyp": "^10.1.0",
"nodemon": "^2.0.13",
"supertest": "^6.3.3",
"vitest": "^2.1.5"
"vitest": "^1.1.3"
},
"publishConfig": {
"access": "public"

View File

@@ -8,7 +8,7 @@ export default {
key: 'instanceUrl',
label: 'WordPress instance URL',
type: 'string',
required: true,
required: false,
readOnly: false,
value: null,
placeholder: null,

View File

@@ -52,7 +52,7 @@ const appConfig = {
isDev: appEnv === 'development',
isTest: appEnv === 'test',
isProd: appEnv === 'production',
version: '0.14.0',
version: '0.13.1',
postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development',
postgresSchema: process.env.POSTGRES_SCHEMA || 'public',
postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'),

View File

@@ -7,7 +7,7 @@ export default async (request, response) => {
.throwIfNotFound();
const roleMappings = await samlAuthProvider
.$relatedQuery('roleMappings')
.$relatedQuery('samlAuthProvidersRoleMappings')
.orderBy('remote_role_name', 'asc');
renderObject(response, roleMappings);

View File

@@ -8,14 +8,15 @@ export default async (request, response) => {
.findById(samlAuthProviderId)
.throwIfNotFound();
const roleMappings = await samlAuthProvider.updateRoleMappings(
roleMappingsParams(request)
);
const samlAuthProvidersRoleMappings =
await samlAuthProvider.updateRoleMappings(
samlAuthProvidersRoleMappingsParams(request)
);
renderObject(response, roleMappings);
renderObject(response, samlAuthProvidersRoleMappings);
};
const roleMappingsParams = (request) => {
const samlAuthProvidersRoleMappingsParams = (request) => {
const roleMappings = request.body;
return roleMappings.map(({ roleId, remoteRoleName }) => ({

View File

@@ -6,7 +6,7 @@ import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by
import { createRole } from '../../../../../../test/factories/role.js';
import { createUser } from '../../../../../../test/factories/user.js';
import { createSamlAuthProvider } from '../../../../../../test/factories/saml-auth-provider.ee.js';
import { createRoleMapping } from '../../../../../../test/factories/role-mapping.js';
import { createSamlAuthProvidersRoleMapping } from '../../../../../../test/factories/saml-auth-providers-role-mapping.js';
import createRoleMappingsMock from '../../../../../../test/mocks/rest/api/v1/admin/saml-auth-providers/update-role-mappings.ee.js';
import * as license from '../../../../../helpers/license.ee.js';
@@ -21,12 +21,12 @@ describe('PATCH /api/v1/admin/saml-auth-providers/:samlAuthProviderId/role-mappi
samlAuthProvider = await createSamlAuthProvider();
await createRoleMapping({
await createSamlAuthProvidersRoleMapping({
samlAuthProviderId: samlAuthProvider.id,
remoteRoleName: 'Viewer',
});
await createRoleMapping({
await createSamlAuthProvidersRoleMapping({
samlAuthProviderId: samlAuthProvider.id,
remoteRoleName: 'Editor',
});
@@ -64,7 +64,7 @@ describe('PATCH /api/v1/admin/saml-auth-providers/:samlAuthProviderId/role-mappi
it('should delete role mappings when given empty role mappings', async () => {
const existingRoleMappings = await samlAuthProvider.$relatedQuery(
'roleMappings'
'samlAuthProvidersRoleMappings'
);
expect(existingRoleMappings.length).toBe(2);
@@ -149,4 +149,34 @@ describe('PATCH /api/v1/admin/saml-auth-providers/:samlAuthProviderId/role-mappi
.send(roleMappings)
.expect(404);
});
it('should not delete existing role mapping when error thrown', async () => {
const roleMappings = [
{
roleId: userRole.id,
remoteRoleName: {
invalid: 'data',
},
},
];
const roleMappingsBeforeRequest = await samlAuthProvider.$relatedQuery(
'samlAuthProvidersRoleMappings'
);
await request(app)
.patch(
`/api/v1/admin/saml-auth-providers/${samlAuthProvider.id}/role-mappings`
)
.set('Authorization', token)
.send(roleMappings)
.expect(422);
const roleMappingsAfterRequest = await samlAuthProvider.$relatedQuery(
'samlAuthProvidersRoleMappings'
);
expect(roleMappingsBeforeRequest).toStrictEqual(roleMappingsAfterRequest);
expect(roleMappingsAfterRequest.length).toBe(2);
});
});

View File

@@ -87,14 +87,14 @@ describe('GET /api/v1/apps/:appKey/connections', () => {
it('should return not found response for invalid connection UUID', async () => {
await createPermission({
action: 'read',
action: 'update',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await request(app)
.get('/api/v1/apps/invalid-connection-id/connections')
.get('/api/v1/connections/invalid-connection-id/connections')
.set('Authorization', token)
.expect(404);
});

View File

@@ -10,7 +10,7 @@ describe('GET /api/v1/automatisch/version', () => {
const expectedPayload = {
data: {
version: '0.14.0',
version: '0.13.1',
},
meta: {
count: 1,

View File

@@ -1,11 +1,11 @@
import { renderObject } from '../../../../helpers/renderer.js';
export default async (request, response) => {
const flow = await request.currentUser.$relatedQuery('flows').insertAndFetch({
let flow = await request.currentUser.$relatedQuery('flows').insert({
name: 'Name your flow',
});
await flow.createInitialSteps();
flow = await flow.createInitialSteps();
renderObject(response, flow, { status: 201 });
};

View File

@@ -6,7 +6,7 @@ export default async (request, response) => {
.findById(request.params.flowId)
.throwIfNotFound();
const createdActionStep = await flow.createStepAfter(
const createdActionStep = await flow.createActionStep(
request.body.previousStepId
);

View File

@@ -193,7 +193,7 @@ describe('POST /api/v1/steps/:stepId/dynamic-data', () => {
const notExistingStepUUID = Crypto.randomUUID();
await request(app)
.post(`/api/v1/steps/${notExistingStepUUID}/dynamic-data`)
.get(`/api/v1/steps/${notExistingStepUUID}/dynamic-data`)
.set('Authorization', token)
.expect(404);
});
@@ -216,7 +216,7 @@ describe('POST /api/v1/steps/:stepId/dynamic-data', () => {
const step = await createStep({ appKey: null });
await request(app)
.post(`/api/v1/steps/${step.id}/dynamic-data`)
.get(`/api/v1/steps/${step.id}/dynamic-data`)
.set('Authorization', token)
.expect(404);
});

View File

@@ -118,7 +118,7 @@ describe('POST /api/v1/steps/:stepId/dynamic-fields', () => {
const notExistingStepUUID = Crypto.randomUUID();
await request(app)
.post(`/api/v1/steps/${notExistingStepUUID}/dynamic-fields`)
.get(`/api/v1/steps/${notExistingStepUUID}/dynamic-fields`)
.set('Authorization', token)
.expect(404);
});
@@ -138,11 +138,10 @@ describe('POST /api/v1/steps/:stepId/dynamic-fields', () => {
conditions: [],
});
const step = await createStep();
await step.$query().patch({ appKey: null });
const step = await createStep({ appKey: null });
await request(app)
.post(`/api/v1/steps/${step.id}/dynamic-fields`)
.get(`/api/v1/steps/${step.id}/dynamic-fields`)
.set('Authorization', token)
.expect(404);
});

View File

@@ -1,52 +0,0 @@
export async function up(knex) {
await knex.schema.createTable('role_mappings', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table
.uuid('saml_auth_provider_id')
.references('id')
.inTable('saml_auth_providers');
table.uuid('role_id').references('id').inTable('roles');
table.string('remote_role_name').notNullable();
table.unique(['saml_auth_provider_id', 'remote_role_name']);
table.timestamps(true, true);
});
const existingRoleMappings = await knex('saml_auth_providers_role_mappings');
if (existingRoleMappings.length) {
await knex('role_mappings').insert(existingRoleMappings);
}
return await knex.schema.dropTable('saml_auth_providers_role_mappings');
}
export async function down(knex) {
await knex.schema.createTable(
'saml_auth_providers_role_mappings',
(table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table
.uuid('saml_auth_provider_id')
.references('id')
.inTable('saml_auth_providers');
table.uuid('role_id').references('id').inTable('roles');
table.string('remote_role_name').notNullable();
table.unique(['saml_auth_provider_id', 'remote_role_name']);
table.timestamps(true, true);
}
);
const existingRoleMappings = await knex('role_mappings');
if (existingRoleMappings.length) {
await knex('saml_auth_providers_role_mappings').insert(
existingRoleMappings
);
}
return await knex.schema.dropTable('role_mappings');
}

View File

@@ -30,7 +30,7 @@ const findOrCreateUserBySamlIdentity = async (
: [mappedUser.role];
const samlAuthProviderRoleMapping = await samlAuthProvider
.$relatedQuery('roleMappings')
.$relatedQuery('samlAuthProvidersRoleMappings')
.whereIn('remote_role_name', mappedRoles)
.limit(1)
.first();

View File

@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest';
import userAbility from './user-ability.js';
describe('userAbility', () => {
it('should return PureAbility instantiated with user permissions', () => {
const user = {
permissions: [
{
subject: 'Flow',
action: 'read',
conditions: ['isCreator'],
},
],
role: {
name: 'User',
},
};
const ability = userAbility(user);
expect(ability.rules).toStrictEqual(user.permissions);
});
it('should return permission-less PureAbility for user with no role', () => {
const user = {
permissions: [
{
subject: 'Flow',
action: 'read',
conditions: ['isCreator'],
},
],
role: null,
};
const ability = userAbility(user);
expect(ability.rules).toStrictEqual([]);
});
it('should return permission-less PureAbility for user with no permissions', () => {
const user = { permissions: null, role: { name: 'User' } };
const ability = userAbility(user);
expect(ability.rules).toStrictEqual([]);
});
});

View File

@@ -1,42 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Flow model > jsonSchema should have correct validations 1`] = `
{
"properties": {
"active": {
"type": "boolean",
},
"createdAt": {
"type": "string",
},
"deletedAt": {
"type": "string",
},
"id": {
"format": "uuid",
"type": "string",
},
"name": {
"minLength": 1,
"type": "string",
},
"publishedAt": {
"type": "string",
},
"remoteWebhookId": {
"type": "string",
},
"updatedAt": {
"type": "string",
},
"userId": {
"format": "uuid",
"type": "string",
},
},
"required": [
"name",
],
"type": "object",
}
`;

View File

@@ -1,30 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`RoleMapping model > jsonSchema should have the correct schema 1`] = `
{
"properties": {
"id": {
"format": "uuid",
"type": "string",
},
"remoteRoleName": {
"minLength": 1,
"type": "string",
},
"roleId": {
"format": "uuid",
"type": "string",
},
"samlAuthProviderId": {
"format": "uuid",
"type": "string",
},
},
"required": [
"samlAuthProviderId",
"roleId",
"remoteRoleName",
],
"type": "object",
}
`;

View File

@@ -1,72 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SamlAuthProvider model > jsonSchema should have the correct schema 1`] = `
{
"properties": {
"active": {
"type": "boolean",
},
"certificate": {
"minLength": 1,
"type": "string",
},
"defaultRoleId": {
"format": "uuid",
"type": "string",
},
"emailAttributeName": {
"minLength": 1,
"type": "string",
},
"entryPoint": {
"minLength": 1,
"type": "string",
},
"firstnameAttributeName": {
"minLength": 1,
"type": "string",
},
"id": {
"format": "uuid",
"type": "string",
},
"issuer": {
"minLength": 1,
"type": "string",
},
"name": {
"minLength": 1,
"type": "string",
},
"roleAttributeName": {
"minLength": 1,
"type": "string",
},
"signatureAlgorithm": {
"enum": [
"sha1",
"sha256",
"sha512",
],
"type": "string",
},
"surnameAttributeName": {
"minLength": 1,
"type": "string",
},
},
"required": [
"name",
"certificate",
"signatureAlgorithm",
"entryPoint",
"issuer",
"firstnameAttributeName",
"surnameAttributeName",
"emailAttributeName",
"roleAttributeName",
"defaultRoleId",
],
"type": "object",
}
`;

View File

@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`RoleMapping model > jsonSchema should have the correct schema 1`] = `
exports[`SamlAuthProvidersRoleMapping model > jsonSchema should have the correct schema 1`] = `
{
"properties": {
"id": {
@@ -28,3 +28,14 @@ exports[`RoleMapping model > jsonSchema should have the correct schema 1`] = `
"type": "object",
}
`;
exports[`SamlAuthProvidersRoleMapping model > relationMappings should have samlAuthProvider relation 1`] = `
{
"join": {
"from": "saml_auth_providers_role_mappings.saml_auth_provider_id",
"to": "saml_auth_providers.id",
},
"modelClass": [Function],
"relation": [Function],
}
`;

View File

@@ -1,77 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Step model > jsonSchema should have correct validations 1`] = `
{
"properties": {
"appKey": {
"maxLength": 255,
"minLength": 1,
"type": [
"string",
"null",
],
},
"connectionId": {
"format": "uuid",
"type": [
"string",
"null",
],
},
"createdAt": {
"type": "string",
},
"deletedAt": {
"type": "string",
},
"flowId": {
"format": "uuid",
"type": "string",
},
"id": {
"format": "uuid",
"type": "string",
},
"key": {
"type": [
"string",
"null",
],
},
"parameters": {
"type": "object",
},
"position": {
"type": "integer",
},
"status": {
"default": "incomplete",
"enum": [
"incomplete",
"completed",
],
"type": "string",
},
"type": {
"enum": [
"action",
"trigger",
],
"type": "string",
},
"updatedAt": {
"type": "string",
},
"webhookPath": {
"type": [
"string",
"null",
],
},
},
"required": [
"type",
],
"type": "object",
}
`;

View File

@@ -1,81 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`User model > jsonSchema should have correct validations 1`] = `
{
"properties": {
"createdAt": {
"type": "string",
},
"deletedAt": {
"type": "string",
},
"email": {
"format": "email",
"maxLength": 255,
"minLength": 1,
"type": "string",
},
"fullName": {
"minLength": 1,
"type": "string",
},
"id": {
"format": "uuid",
"type": "string",
},
"invitationToken": {
"type": [
"string",
"null",
],
},
"invitationTokenSentAt": {
"format": "date-time",
"type": [
"string",
"null",
],
},
"password": {
"minLength": 6,
"type": "string",
},
"resetPasswordToken": {
"type": [
"string",
"null",
],
},
"resetPasswordTokenSentAt": {
"format": "date-time",
"type": [
"string",
"null",
],
},
"roleId": {
"format": "uuid",
"type": "string",
},
"status": {
"default": "active",
"enum": [
"active",
"invited",
],
"type": "string",
},
"trialExpiryDate": {
"type": "string",
},
"updatedAt": {
"type": "string",
},
},
"required": [
"fullName",
"email",
],
"type": "object",
}
`;

View File

@@ -88,13 +88,15 @@ class Flow extends Base {
},
});
static async populateStatusProperty(flows) {
const referenceFlow = flows[0];
static async afterFind(args) {
const { result } = args;
const referenceFlow = result[0];
if (referenceFlow) {
const shouldBePaused = await referenceFlow.isPaused();
for (const flow of flows) {
for (const flow of result) {
if (!flow.active) {
flow.status = 'draft';
} else if (flow.active && shouldBePaused) {
@@ -106,10 +108,6 @@ class Flow extends Base {
}
}
static async afterFind(args) {
await this.populateStatusProperty(args.result);
}
async lastInternalId() {
const lastExecution = await this.$relatedQuery('lastExecution');
@@ -125,14 +123,13 @@ class Flow extends Base {
return lastExecutions.map((execution) => execution.internalId);
}
static get IncompleteStepsError() {
get IncompleteStepsError() {
return new ValidationError({
data: {
flow: [
{
message:
'All steps should be completed before updating flow status!',
},
message: 'All steps should be completed before updating flow status!'
}
],
},
type: 'incompleteStepsError',
@@ -151,48 +148,36 @@ class Flow extends Base {
type: 'action',
position: 2,
});
return this.$query().withGraphFetched('steps');
}
async getStepById(stepId) {
return await this.$relatedQuery('steps').findById(stepId).throwIfNotFound();
}
async createActionStep(previousStepId) {
const previousStep = await this.$relatedQuery('steps')
.findById(previousStepId)
.throwIfNotFound();
async insertActionStepAtPosition(position) {
return await this.$relatedQuery('steps').insertAndFetch({
const createdStep = await this.$relatedQuery('steps').insertAndFetch({
type: 'action',
position,
position: previousStep.position + 1,
});
}
async getStepsAfterPosition(position) {
return await this.$relatedQuery('steps').where('position', '>', position);
}
const nextSteps = await this.$relatedQuery('steps')
.where('position', '>=', createdStep.position)
.whereNot('id', createdStep.id);
async updateStepPositionsFrom(startPosition, steps) {
const stepPositionUpdates = steps.map(async (step, index) => {
return await step.$query().patch({
position: startPosition + index,
const nextStepQueries = nextSteps.map(async (nextStep, index) => {
return await nextStep.$query().patchAndFetch({
position: createdStep.position + index + 1,
});
});
return await Promise.all(stepPositionUpdates);
}
async createStepAfter(previousStepId) {
const previousStep = await this.getStepById(previousStepId);
const nextSteps = await this.getStepsAfterPosition(previousStep.position);
const createdStep = await this.insertActionStepAtPosition(
previousStep.position + 1
);
await this.updateStepPositionsFrom(createdStep.position + 1, nextSteps);
await Promise.all(nextStepQueries);
return createdStep;
}
async unregisterWebhook() {
async delete() {
const triggerStep = await this.getTriggerStep();
const trigger = await triggerStep?.getTriggerCommand();
@@ -213,33 +198,15 @@ class Flow extends Base {
);
}
}
}
async deleteExecutionSteps() {
const executionIds = (
await this.$relatedQuery('executions').select('executions.id')
).map((execution) => execution.id);
return await ExecutionStep.query()
.delete()
.whereIn('execution_id', executionIds);
}
async deleteExecutions() {
return await this.$relatedQuery('executions').delete();
}
async deleteSteps() {
return await this.$relatedQuery('steps').delete();
}
async delete() {
await this.unregisterWebhook();
await this.deleteExecutionSteps();
await this.deleteExecutions();
await this.deleteSteps();
await ExecutionStep.query().delete().whereIn('execution_id', executionIds);
await this.$relatedQuery('executions').delete();
await this.$relatedQuery('steps').delete();
await this.$query().delete();
}
@@ -324,18 +291,6 @@ class Flow extends Base {
return duplicatedFlowWithSteps;
}
async getTriggerStep() {
return await this.$relatedQuery('steps').findOne({
type: 'trigger',
});
}
async isPaused() {
const user = await this.$relatedQuery('user').withSoftDeleted();
const allowedToRunFlows = await user.isAllowedToRunFlows();
return allowedToRunFlows ? false : true;
}
async updateStatus(newActiveValue) {
if (this.active === newActiveValue) {
return this;
@@ -344,7 +299,7 @@ class Flow extends Base {
const triggerStep = await this.getTriggerStep();
if (triggerStep.status === 'incomplete') {
throw Flow.IncompleteStepsError;
throw this.IncompleteStepsError;
}
const trigger = await triggerStep.getTriggerCommand();
@@ -398,55 +353,60 @@ class Flow extends Base {
});
}
async throwIfHavingIncompleteSteps() {
const incompleteStep = await this.$relatedQuery('steps').findOne({
async $beforeUpdate(opt, queryContext) {
await super.$beforeUpdate(opt, queryContext);
if (!this.active) return;
const oldFlow = opt.old;
const incompleteStep = await oldFlow.$relatedQuery('steps').findOne({
status: 'incomplete',
});
if (incompleteStep) {
throw Flow.IncompleteStepsError;
throw this.IncompleteStepsError;
}
}
async throwIfHavingLessThanTwoSteps() {
const allSteps = await this.$relatedQuery('steps');
const allSteps = await oldFlow.$relatedQuery('steps');
if (allSteps.length < 2) {
throw new ValidationError({
data: {
flow: [
{
message:
'There should be at least one trigger and one action steps in the flow!',
},
message: 'There should be at least one trigger and one action steps in the flow!'
}
],
},
type: 'insufficientStepsError',
});
}
}
async $beforeUpdate(opt, queryContext) {
await super.$beforeUpdate(opt, queryContext);
if (this.active) {
await opt.old.throwIfHavingIncompleteSteps();
await opt.old.throwIfHavingLessThanTwoSteps();
}
return;
}
async $afterInsert(queryContext) {
await super.$afterInsert(queryContext);
Telemetry.flowCreated(this);
}
async $afterUpdate(opt, queryContext) {
await super.$afterUpdate(opt, queryContext);
Telemetry.flowUpdated(this);
}
async getTriggerStep() {
return await this.$relatedQuery('steps').findOne({
type: 'trigger',
});
}
async isPaused() {
const user = await this.$relatedQuery('user').withSoftDeleted();
const allowedToRunFlows = await user.isAllowedToRunFlows();
return allowedToRunFlows ? false : true;
}
}
export default Flow;

View File

@@ -1,616 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import Flow from './flow.js';
import User from './user.js';
import Base from './base.js';
import Step from './step.js';
import Execution from './execution.js';
import Telemetry from '../helpers/telemetry/index.js';
import * as globalVariableModule from '../helpers/global-variable.js';
import { createFlow } from '../../test/factories/flow.js';
import { createStep } from '../../test/factories/step.js';
import { createExecution } from '../../test/factories/execution.js';
import { createExecutionStep } from '../../test/factories/execution-step.js';
describe('Flow model', () => {
it('tableName should return correct name', () => {
expect(Flow.tableName).toBe('flows');
});
it('jsonSchema should have correct validations', () => {
expect(Flow.jsonSchema).toMatchSnapshot();
});
describe('relationMappings', () => {
it('should return correct associations', () => {
const relationMappings = Flow.relationMappings();
const expectedRelations = {
steps: {
relation: Base.HasManyRelation,
modelClass: Step,
join: {
from: 'flows.id',
to: 'steps.flow_id',
},
filter: expect.any(Function),
},
triggerStep: {
relation: Base.HasOneRelation,
modelClass: Step,
join: {
from: 'flows.id',
to: 'steps.flow_id',
},
filter: expect.any(Function),
},
executions: {
relation: Base.HasManyRelation,
modelClass: Execution,
join: {
from: 'flows.id',
to: 'executions.flow_id',
},
},
lastExecution: {
relation: Base.HasOneRelation,
modelClass: Execution,
join: {
from: 'flows.id',
to: 'executions.flow_id',
},
filter: expect.any(Function),
},
user: {
relation: Base.HasOneRelation,
modelClass: User,
join: {
from: 'flows.user_id',
to: 'users.id',
},
},
};
expect(relationMappings).toStrictEqual(expectedRelations);
});
it('steps should return the steps', () => {
const relations = Flow.relationMappings();
const orderBySpy = vi.fn();
relations.steps.filter({ orderBy: orderBySpy });
expect(orderBySpy).toHaveBeenCalledWith('position', 'asc');
});
it('triggerStep should return the trigger step', () => {
const relations = Flow.relationMappings();
const firstSpy = vi.fn();
const limitSpy = vi.fn().mockImplementation(() => ({
first: firstSpy,
}));
const whereSpy = vi.fn().mockImplementation(() => ({
limit: limitSpy,
}));
relations.triggerStep.filter({ where: whereSpy });
expect(whereSpy).toHaveBeenCalledWith('type', 'trigger');
expect(limitSpy).toHaveBeenCalledWith(1);
expect(firstSpy).toHaveBeenCalledOnce();
});
it('lastExecution should return the last execution', () => {
const relations = Flow.relationMappings();
const firstSpy = vi.fn();
const limitSpy = vi.fn().mockImplementation(() => ({
first: firstSpy,
}));
const orderBySpy = vi.fn().mockImplementation(() => ({
limit: limitSpy,
}));
relations.lastExecution.filter({ orderBy: orderBySpy });
expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc');
expect(limitSpy).toHaveBeenCalledWith(1);
expect(firstSpy).toHaveBeenCalledOnce();
});
});
describe('populateStatusProperty', () => {
it('should assign "draft" to status property when a flow is not active', async () => {
const referenceFlow = await createFlow({ active: false });
const flows = [referenceFlow];
vi.spyOn(referenceFlow, 'isPaused').mockResolvedValue();
await Flow.populateStatusProperty(flows);
expect(referenceFlow.status).toBe('draft');
});
it('should assign "paused" to status property when a flow is active, but should be paused', async () => {
const referenceFlow = await createFlow({ active: true });
const flows = [referenceFlow];
vi.spyOn(referenceFlow, 'isPaused').mockResolvedValue(true);
await Flow.populateStatusProperty(flows);
expect(referenceFlow.status).toBe('paused');
});
it('should assign "published" to status property when a flow is active', async () => {
const referenceFlow = await createFlow({ active: true });
const flows = [referenceFlow];
vi.spyOn(referenceFlow, 'isPaused').mockResolvedValue(false);
await Flow.populateStatusProperty(flows);
expect(referenceFlow.status).toBe('published');
});
});
it('afterFind should call Flow.populateStatusProperty', async () => {
const populateStatusPropertySpy = vi
.spyOn(Flow, 'populateStatusProperty')
.mockImplementation(() => {});
await createFlow();
expect(populateStatusPropertySpy).toHaveBeenCalledOnce();
});
describe('lastInternalId', () => {
it('should return internal ID of last execution when exists', async () => {
const flow = await createFlow();
await createExecution({ flowId: flow.id });
await createExecution({ flowId: flow.id });
const lastExecution = await createExecution({ flowId: flow.id });
expect(await flow.lastInternalId()).toBe(lastExecution.internalId);
});
it('should return null when no flow execution exists', async () => {
const flow = await createFlow();
expect(await flow.lastInternalId()).toBe(null);
});
});
describe('lastInternalIds', () => {
it('should return last internal IDs', async () => {
const flow = await createFlow();
const internalIds = [
await createExecution({ flowId: flow.id }),
await createExecution({ flowId: flow.id }),
await createExecution({ flowId: flow.id }),
].map((execution) => execution.internalId);
expect(await flow.lastInternalIds()).toStrictEqual(internalIds);
});
it('should return last 50 internal IDs by default', async () => {
const flow = new Flow();
const limitSpy = vi.fn().mockResolvedValue([]);
vi.spyOn(flow, '$relatedQuery').mockReturnValue({
select: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: limitSpy,
});
await flow.lastInternalIds();
expect(limitSpy).toHaveBeenCalledWith(50);
});
});
it('IncompleteStepsError should return validation error for incomplete steps', () => {
expect(() => {
throw Flow.IncompleteStepsError;
}).toThrowError(
'flow: All steps should be completed before updating flow status!'
);
});
it('createInitialSteps should create one trigger and one action step', async () => {
const flow = await createFlow();
await flow.createInitialSteps();
const steps = await flow.$relatedQuery('steps');
expect(steps.length).toBe(2);
expect(steps[0]).toMatchObject({
flowId: flow.id,
type: 'trigger',
position: 1,
});
expect(steps[1]).toMatchObject({
flowId: flow.id,
type: 'action',
position: 2,
});
});
it('getStepById should return the step with the given ID from the flow', async () => {
const flow = await createFlow();
const step = await createStep({ flowId: flow.id });
expect(await flow.getStepById(step.id)).toStrictEqual(step);
});
it('insertActionStepAtPosition should insert action step at given position', async () => {
const flow = await createFlow();
await flow.createInitialSteps();
const createdStep = await flow.insertActionStepAtPosition(2);
expect(createdStep).toMatchObject({
type: 'action',
position: 2,
});
});
it('getStepsAfterPosition should return steps after the given position', async () => {
const flow = await createFlow();
await flow.createInitialSteps();
await createStep({ flowId: flow.id });
expect(await flow.getStepsAfterPosition(1)).toMatchObject([
{ position: 2 },
{ position: 3 },
]);
});
it('updateStepPositionsFrom', async () => {
const flow = await createFlow();
await createStep({ type: 'trigger', flowId: flow.id, position: 6 });
await createStep({ type: 'action', flowId: flow.id, position: 8 });
await createStep({ type: 'action', flowId: flow.id, position: 10 });
await flow.updateStepPositionsFrom(2, await flow.$relatedQuery('steps'));
expect(await flow.$relatedQuery('steps')).toMatchObject([
{ position: 2, type: 'trigger' },
{ position: 3, type: 'action' },
{ position: 4, type: 'action' },
]);
});
it('createStepAfter should create an action step after given step ID', async () => {
const flow = await createFlow();
const triggerStep = await createStep({ type: 'trigger', flowId: flow.id });
const actionStep = await createStep({ type: 'action', flowId: flow.id });
const createdStep = await flow.createStepAfter(triggerStep.id);
const refetchedActionStep = await actionStep.$query();
expect(createdStep).toMatchObject({ type: 'action', position: 2 });
expect(refetchedActionStep.position).toBe(3);
});
describe('unregisterWebhook', () => {
it('should unregister webhook on remote when supported', async () => {
const flow = await createFlow();
const triggerStep = await createStep({
flowId: flow.id,
appKey: 'typeform',
key: 'new-entry',
type: 'trigger',
});
const unregisterHookSpy = vi.fn().mockResolvedValue();
vi.spyOn(Step.prototype, 'getTriggerCommand').mockResolvedValue({
type: 'webhook',
unregisterHook: unregisterHookSpy,
});
const globalVariableSpy = vi
.spyOn(globalVariableModule, 'default')
.mockResolvedValue('global-variable');
await flow.unregisterWebhook();
expect(unregisterHookSpy).toHaveBeenCalledWith('global-variable');
expect(globalVariableSpy).toHaveBeenCalledWith({
flow,
step: triggerStep,
connection: undefined,
app: await triggerStep.getApp(),
});
});
it('should silently fail when unregistration fails', async () => {
const flow = await createFlow();
await createStep({
flowId: flow.id,
appKey: 'typeform',
key: 'new-entry',
type: 'trigger',
});
const unregisterHookSpy = vi.fn().mockRejectedValue(new Error());
vi.spyOn(Step.prototype, 'getTriggerCommand').mockResolvedValue({
type: 'webhook',
unregisterHook: unregisterHookSpy,
});
expect(await flow.unregisterWebhook()).toBe(undefined);
expect(unregisterHookSpy).toHaveBeenCalledOnce();
});
it('should do nothing when trigger step is not webhook', async () => {
const flow = await createFlow();
await createStep({
flowId: flow.id,
type: 'trigger',
});
const unregisterHookSpy = vi.fn().mockRejectedValue(new Error());
expect(await flow.unregisterWebhook()).toBe(undefined);
expect(unregisterHookSpy).not.toHaveBeenCalled();
});
});
it('deleteExecutionSteps should delete related execution steps', async () => {
const flow = await createFlow();
const execution = await createExecution({ flowId: flow.id });
const firstExecutionStep = await createExecutionStep({
executionId: execution.id,
});
const secondExecutionStep = await createExecutionStep({
executionId: execution.id,
});
await flow.deleteExecutionSteps();
expect(await firstExecutionStep.$query()).toBe(undefined);
expect(await secondExecutionStep.$query()).toBe(undefined);
});
it('deleteExecutions should delete related executions', async () => {
const flow = await createFlow();
const firstExecution = await createExecution({ flowId: flow.id });
const secondExecution = await createExecution({ flowId: flow.id });
await flow.deleteExecutions();
expect(await firstExecution.$query()).toBe(undefined);
expect(await secondExecution.$query()).toBe(undefined);
});
it('deleteSteps should delete related steps', async () => {
const flow = await createFlow();
await flow.createInitialSteps();
await flow.deleteSteps();
expect(await flow.$relatedQuery('steps')).toStrictEqual([]);
});
it('delete should delete the flow with its relations', async () => {
const flow = await createFlow();
const unregisterWebhookSpy = vi
.spyOn(flow, 'unregisterWebhook')
.mockResolvedValue();
const deleteExecutionStepsSpy = vi
.spyOn(flow, 'deleteExecutionSteps')
.mockResolvedValue();
const deleteExecutionsSpy = vi
.spyOn(flow, 'deleteExecutions')
.mockResolvedValue();
const deleteStepsSpy = vi.spyOn(flow, 'deleteSteps').mockResolvedValue();
await flow.delete();
expect(unregisterWebhookSpy).toHaveBeenCalledOnce();
expect(deleteExecutionStepsSpy).toHaveBeenCalledOnce();
expect(deleteExecutionsSpy).toHaveBeenCalledOnce();
expect(deleteStepsSpy).toHaveBeenCalledOnce();
expect(await flow.$query()).toBe(undefined);
});
it.todo('duplicateFor');
it('getTriggerStep', async () => {
const flow = await createFlow();
const triggerStep = await createStep({ flowId: flow.id, type: 'trigger' });
await createStep({ flowId: flow.id, type: 'action' });
expect(await flow.getTriggerStep()).toStrictEqual(triggerStep);
});
describe('isPaused', () => {
it('should return true when user.isAllowedToRunFlows returns false', async () => {
const flow = await createFlow();
const isAllowedToRunFlowsSpy = vi.fn().mockResolvedValue(false);
vi.spyOn(flow, '$relatedQuery').mockReturnValue({
withSoftDeleted: vi.fn().mockReturnThis(),
isAllowedToRunFlows: isAllowedToRunFlowsSpy,
});
expect(await flow.isPaused()).toBe(true);
expect(isAllowedToRunFlowsSpy).toHaveBeenCalledOnce();
});
it('should return false when user.isAllowedToRunFlows returns true', async () => {
const flow = await createFlow();
const isAllowedToRunFlowsSpy = vi.fn().mockResolvedValue(true);
vi.spyOn(flow, '$relatedQuery').mockReturnValue({
withSoftDeleted: vi.fn().mockReturnThis(),
isAllowedToRunFlows: isAllowedToRunFlowsSpy,
});
expect(await flow.isPaused()).toBe(false);
expect(isAllowedToRunFlowsSpy).toHaveBeenCalledOnce();
});
});
describe('throwIfHavingIncompleteSteps', () => {
it('should throw validation error with incomplete steps', async () => {
const flow = await createFlow();
await flow.createInitialSteps();
await expect(() =>
flow.throwIfHavingIncompleteSteps()
).rejects.toThrowError(
'flow: All steps should be completed before updating flow status!'
);
});
it('should return undefined when all steps are completed', async () => {
const flow = await createFlow();
await createStep({
flowId: flow.id,
status: 'completed',
type: 'trigger',
});
await createStep({
flowId: flow.id,
status: 'completed',
type: 'action',
});
expect(await flow.throwIfHavingIncompleteSteps()).toBe(undefined);
});
});
describe('throwIfHavingLessThanTwoSteps', () => {
it('should throw validation error with less than two steps', async () => {
const flow = await createFlow();
await expect(() =>
flow.throwIfHavingLessThanTwoSteps()
).rejects.toThrowError(
'flow: There should be at least one trigger and one action steps in the flow!'
);
});
it('should return undefined when there are at least two steps', async () => {
const flow = await createFlow();
await createStep({
flowId: flow.id,
type: 'trigger',
});
await createStep({
flowId: flow.id,
type: 'action',
});
expect(await flow.throwIfHavingLessThanTwoSteps()).toBe(undefined);
});
});
describe('$beforeUpdate', () => {
it('should invoke throwIfHavingIncompleteSteps when flow is becoming active', async () => {
const flow = await createFlow({ active: false });
const throwIfHavingIncompleteStepsSpy = vi
.spyOn(Flow.prototype, 'throwIfHavingIncompleteSteps')
.mockImplementation(() => {});
const throwIfHavingLessThanTwoStepsSpy = vi
.spyOn(Flow.prototype, 'throwIfHavingLessThanTwoSteps')
.mockImplementation(() => {});
await flow.$query().patch({ active: true });
expect(throwIfHavingIncompleteStepsSpy).toHaveBeenCalledOnce();
expect(throwIfHavingLessThanTwoStepsSpy).toHaveBeenCalledOnce();
});
it('should invoke throwIfHavingIncompleteSteps when flow is not becoming active', async () => {
const flow = await createFlow({ active: true });
const throwIfHavingIncompleteStepsSpy = vi
.spyOn(Flow.prototype, 'throwIfHavingIncompleteSteps')
.mockImplementation(() => {});
const throwIfHavingLessThanTwoStepsSpy = vi
.spyOn(Flow.prototype, 'throwIfHavingLessThanTwoSteps')
.mockImplementation(() => {});
await flow.$query().patch({});
expect(throwIfHavingIncompleteStepsSpy).not.toHaveBeenCalledOnce();
expect(throwIfHavingLessThanTwoStepsSpy).not.toHaveBeenCalledOnce();
});
});
describe('$afterInsert', () => {
it('should call super.$afterInsert', async () => {
const superAfterInsertSpy = vi.spyOn(Base.prototype, '$afterInsert');
await createFlow();
expect(superAfterInsertSpy).toHaveBeenCalled();
});
it('should call Telemetry.flowCreated', async () => {
const telemetryFlowCreatedSpy = vi
.spyOn(Telemetry, 'flowCreated')
.mockImplementation(() => {});
const flow = await createFlow();
expect(telemetryFlowCreatedSpy).toHaveBeenCalledWith(flow);
});
});
describe('$afterUpdate', () => {
it('should call super.$afterUpdate', async () => {
const superAfterUpdateSpy = vi.spyOn(Base.prototype, '$afterUpdate');
const flow = await createFlow();
await flow.$query().patch({ active: false });
expect(superAfterUpdateSpy).toHaveBeenCalledOnce();
});
it('$afterUpdate should call Telemetry.flowUpdated', async () => {
const telemetryFlowUpdatedSpy = vi
.spyOn(Telemetry, 'flowUpdated')
.mockImplementation(() => {});
const flow = await createFlow();
await flow.$query().patch({ active: false });
expect(telemetryFlowUpdatedSpy).toHaveBeenCalled({});
});
});
});

View File

@@ -5,7 +5,7 @@ import appConfig from '../config/app.js';
import axios from '../helpers/axios-with-proxy.js';
import Base from './base.js';
import Identity from './identity.ee.js';
import RoleMapping from './role-mapping.ee.js';
import SamlAuthProvidersRoleMapping from './saml-auth-providers-role-mapping.ee.js';
class SamlAuthProvider extends Base {
static tableName = 'saml_auth_providers';
@@ -53,12 +53,12 @@ class SamlAuthProvider extends Base {
to: 'saml_auth_providers.id',
},
},
roleMappings: {
samlAuthProvidersRoleMappings: {
relation: Base.HasManyRelation,
modelClass: RoleMapping,
modelClass: SamlAuthProvidersRoleMapping,
join: {
from: 'saml_auth_providers.id',
to: 'role_mappings.saml_auth_provider_id',
to: 'saml_auth_providers_role_mappings.saml_auth_provider_id',
},
},
});
@@ -133,22 +133,27 @@ class SamlAuthProvider extends Base {
}
async updateRoleMappings(roleMappings) {
await this.$relatedQuery('roleMappings').delete();
return await SamlAuthProvider.transaction(async (trx) => {
await this.$relatedQuery('samlAuthProvidersRoleMappings', trx).delete();
if (isEmpty(roleMappings)) {
return [];
}
if (isEmpty(roleMappings)) {
return [];
}
const roleMappingsData = roleMappings.map((roleMapping) => ({
...roleMapping,
samlAuthProviderId: this.id,
}));
const samlAuthProvidersRoleMappingsData = roleMappings.map(
(samlAuthProvidersRoleMapping) => ({
...samlAuthProvidersRoleMapping,
samlAuthProviderId: this.id,
})
);
const newRoleMappings = await RoleMapping.query().insertAndFetch(
roleMappingsData
);
const samlAuthProvidersRoleMappings =
await SamlAuthProvidersRoleMapping.query(trx).insertAndFetch(
samlAuthProvidersRoleMappingsData
);
return newRoleMappings;
return samlAuthProvidersRoleMappings;
});
}
}

View File

@@ -1,231 +0,0 @@
import { vi, beforeEach, describe, it, expect } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import SamlAuthProvider from '../models/saml-auth-provider.ee';
import RoleMapping from '../models/role-mapping.ee';
import axios from '../helpers/axios-with-proxy.js';
import Identity from './identity.ee';
import Base from './base';
import appConfig from '../config/app';
import { createSamlAuthProvider } from '../../test/factories/saml-auth-provider.ee.js';
import { createRoleMapping } from '../../test/factories/role-mapping.js';
import { createRole } from '../../test/factories/role.js';
describe('SamlAuthProvider model', () => {
it('tableName should return correct name', () => {
expect(SamlAuthProvider.tableName).toBe('saml_auth_providers');
});
it('jsonSchema should have the correct schema', () => {
expect(SamlAuthProvider.jsonSchema).toMatchSnapshot();
});
it('relationMappings should return correct associations', () => {
const relationMappings = SamlAuthProvider.relationMappings();
const expectedRelations = {
identities: {
relation: Base.HasOneRelation,
modelClass: Identity,
join: {
from: 'identities.provider_id',
to: 'saml_auth_providers.id',
},
},
roleMappings: {
relation: Base.HasManyRelation,
modelClass: RoleMapping,
join: {
from: 'saml_auth_providers.id',
to: 'role_mappings.saml_auth_provider_id',
},
},
};
expect(relationMappings).toStrictEqual(expectedRelations);
});
it('virtualAttributes should return correct attributes', () => {
const virtualAttributes = SamlAuthProvider.virtualAttributes;
const expectedAttributes = ['loginUrl', 'remoteLogoutUrl'];
expect(virtualAttributes).toStrictEqual(expectedAttributes);
});
it('loginUrl should return the URL of login', () => {
const samlAuthProvider = new SamlAuthProvider();
samlAuthProvider.issuer = 'sample-issuer';
vi.spyOn(appConfig, 'baseUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
expect(samlAuthProvider.loginUrl).toStrictEqual(
'https://automatisch.io/login/saml/sample-issuer'
);
});
it('loginCallbackUrl should return the URL of login callback', () => {
const samlAuthProvider = new SamlAuthProvider();
samlAuthProvider.issuer = 'sample-issuer';
vi.spyOn(appConfig, 'baseUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
expect(samlAuthProvider.loginCallBackUrl).toStrictEqual(
'https://automatisch.io/login/saml/sample-issuer/callback'
);
});
it('remoteLogoutUrl should return the URL from entrypoint', () => {
const samlAuthProvider = new SamlAuthProvider();
samlAuthProvider.entryPoint = 'https://example.com/saml/logout';
expect(samlAuthProvider.remoteLogoutUrl).toStrictEqual(
'https://example.com/saml/logout'
);
});
it('config should return the correct configuration object', () => {
const samlAuthProvider = new SamlAuthProvider();
samlAuthProvider.certificate = 'sample-certificate';
samlAuthProvider.signatureAlgorithm = 'sha256';
samlAuthProvider.entryPoint = 'https://example.com/saml';
samlAuthProvider.issuer = 'sample-issuer';
vi.spyOn(appConfig, 'baseUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
const expectedConfig = {
callbackUrl: 'https://automatisch.io/login/saml/sample-issuer/callback',
cert: 'sample-certificate',
entryPoint: 'https://example.com/saml',
issuer: 'sample-issuer',
signatureAlgorithm: 'sha256',
logoutUrl: 'https://example.com/saml',
};
expect(samlAuthProvider.config).toStrictEqual(expectedConfig);
});
it('generateLogoutRequestBody should return a correctly encoded SAML logout request', () => {
vi.mock('uuid', () => ({
v4: vi.fn(),
}));
const samlAuthProvider = new SamlAuthProvider();
samlAuthProvider.entryPoint = 'https://example.com/saml';
samlAuthProvider.issuer = 'sample-issuer';
const mockUuid = '123e4567-e89b-12d3-a456-426614174000';
uuidv4.mockReturnValue(mockUuid);
const sessionId = 'test-session-id';
const logoutRequest = samlAuthProvider.generateLogoutRequestBody(sessionId);
const expectedLogoutRequest = `
<samlp:LogoutRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
ID="${mockUuid}"
Version="2.0"
IssueInstant="${new Date().toISOString()}"
Destination="https://example.com/saml">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">sample-issuer</saml:Issuer>
<samlp:SessionIndex>test-session-id</samlp:SessionIndex>
</samlp:LogoutRequest>
`;
const expectedEncodedRequest = Buffer.from(expectedLogoutRequest).toString(
'base64'
);
expect(logoutRequest).toBe(expectedEncodedRequest);
});
it('terminateRemoteSession should send the correct POST request and return the response', async () => {
vi.mock('../helpers/axios-with-proxy.js', () => ({
default: {
post: vi.fn(),
},
}));
const samlAuthProvider = new SamlAuthProvider();
samlAuthProvider.entryPoint = 'https://example.com/saml';
samlAuthProvider.generateLogoutRequestBody = vi
.fn()
.mockReturnValue('mockEncodedLogoutRequest');
const sessionId = 'test-session-id';
const mockResponse = { data: 'Logout Successful' };
axios.post.mockResolvedValue(mockResponse);
const response = await samlAuthProvider.terminateRemoteSession(sessionId);
expect(samlAuthProvider.generateLogoutRequestBody).toHaveBeenCalledWith(
sessionId
);
expect(axios.post).toHaveBeenCalledWith(
'https://example.com/saml',
'SAMLRequest=mockEncodedLogoutRequest',
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
expect(response).toBe(mockResponse);
});
describe('updateRoleMappings', () => {
let samlAuthProvider;
beforeEach(async () => {
samlAuthProvider = await createSamlAuthProvider();
});
it('should remove all existing role mappings', async () => {
await createRoleMapping({
samlAuthProviderId: samlAuthProvider.id,
remoteRoleName: 'Admin',
});
await createRoleMapping({
samlAuthProviderId: samlAuthProvider.id,
remoteRoleName: 'User',
});
await samlAuthProvider.updateRoleMappings([]);
const roleMappings = await samlAuthProvider.$relatedQuery('roleMappings');
expect(roleMappings).toStrictEqual([]);
});
it('should return the updated role mappings when new ones are provided', async () => {
const adminRole = await createRole({ name: 'Admin' });
const userRole = await createRole({ name: 'User' });
const newRoleMappings = [
{ remoteRoleName: 'Admin', roleId: adminRole.id },
{ remoteRoleName: 'User', roleId: userRole.id },
];
const result = await samlAuthProvider.updateRoleMappings(newRoleMappings);
const refetchedRoleMappings = await samlAuthProvider.$relatedQuery(
'roleMappings'
);
expect(result).toStrictEqual(refetchedRoleMappings);
});
});
});

View File

@@ -1,8 +1,8 @@
import Base from './base.js';
import SamlAuthProvider from './saml-auth-provider.ee.js';
class RoleMapping extends Base {
static tableName = 'role_mappings';
class SamlAuthProvidersRoleMapping extends Base {
static tableName = 'saml_auth_providers_role_mappings';
static jsonSchema = {
type: 'object',
@@ -21,11 +21,11 @@ class RoleMapping extends Base {
relation: Base.BelongsToOneRelation,
modelClass: SamlAuthProvider,
join: {
from: 'role_mappings.saml_auth_provider_id',
from: 'saml_auth_providers_role_mappings.saml_auth_provider_id',
to: 'saml_auth_providers.id',
},
},
});
}
export default RoleMapping;
export default SamlAuthProvidersRoleMapping;

View File

@@ -1,26 +1,28 @@
import { describe, it, expect } from 'vitest';
import RoleMapping from './role-mapping.ee';
import SamlAuthProvidersRoleMapping from '../models/saml-auth-providers-role-mapping.ee';
import SamlAuthProvider from './saml-auth-provider.ee';
import Base from './base';
describe('RoleMapping model', () => {
describe('SamlAuthProvidersRoleMapping model', () => {
it('tableName should return correct name', () => {
expect(RoleMapping.tableName).toBe('role_mappings');
expect(SamlAuthProvidersRoleMapping.tableName).toBe(
'saml_auth_providers_role_mappings'
);
});
it('jsonSchema should have the correct schema', () => {
expect(RoleMapping.jsonSchema).toMatchSnapshot();
expect(SamlAuthProvidersRoleMapping.jsonSchema).toMatchSnapshot();
});
it('relationMappings should return correct associations', () => {
const relationMappings = RoleMapping.relationMappings();
const relationMappings = SamlAuthProvidersRoleMapping.relationMappings();
const expectedRelations = {
samlAuthProvider: {
relation: Base.BelongsToOneRelation,
modelClass: SamlAuthProvider,
join: {
from: 'role_mappings.saml_auth_provider_id',
from: 'saml_auth_providers_role_mappings.saml_auth_provider_id',
to: 'saml_auth_providers.id',
},
},

View File

@@ -93,14 +93,6 @@ class Step extends Base {
return `${appConfig.baseUrl}/apps/${this.appKey}/assets/favicon.svg`;
}
get isTrigger() {
return this.type === 'trigger';
}
get isAction() {
return this.type === 'action';
}
async computeWebhookPath() {
if (this.type === 'action') return null;
@@ -143,6 +135,24 @@ class Step extends Base {
return webhookUrl;
}
async $afterInsert(queryContext) {
await super.$afterInsert(queryContext);
Telemetry.stepCreated(this);
}
async $afterUpdate(opt, queryContext) {
await super.$afterUpdate(opt, queryContext);
Telemetry.stepUpdated(this);
}
get isTrigger() {
return this.type === 'trigger';
}
get isAction() {
return this.type === 'action';
}
async getApp() {
if (!this.appKey) return null;
@@ -160,7 +170,12 @@ class Step extends Base {
}
async getLastExecutionStep() {
return await this.$relatedQuery('lastExecutionStep');
const lastExecutionStep = await this.$relatedQuery('executionSteps')
.orderBy('created_at', 'desc')
.limit(1)
.first();
return lastExecutionStep;
}
async getNextStep() {
@@ -192,18 +207,19 @@ class Step extends Base {
}
async getSetupFields() {
let substeps;
let setupSupsteps;
if (this.isTrigger) {
substeps = (await this.getTriggerCommand()).substeps;
setupSupsteps = (await this.getTriggerCommand()).substeps;
} else {
substeps = (await this.getActionCommand()).substeps;
setupSupsteps = (await this.getActionCommand()).substeps;
}
const setupSubstep = substeps.find(
const existingArguments = setupSupsteps.find(
(substep) => substep.key === 'chooseTrigger'
);
return setupSubstep.arguments;
).arguments;
return existingArguments;
}
async getSetupAndDynamicFields() {
@@ -310,17 +326,23 @@ class Step extends Base {
.$relatedQuery('steps')
.where('position', '>', this.position);
await flow.updateStepPositionsFrom(this.position, nextSteps);
const nextStepQueries = nextSteps.map(async (nextStep) => {
await nextStep.$query().patch({
position: nextStep.position - 1,
});
});
await Promise.all(nextStepQueries);
}
async updateFor(user, newStepData) {
const { appKey = this.appKey, connectionId, key, parameters } = newStepData;
const { connectionId, appKey, key, parameters } = newStepData;
if (connectionId && appKey) {
if (connectionId && (appKey || this.appKey)) {
await user.authorizedConnections
.findOne({
id: connectionId,
key: appKey,
key: appKey || this.appKey,
})
.throwIfNotFound();
}
@@ -334,8 +356,8 @@ class Step extends Base {
}
const updatedStep = await this.$query().patchAndFetch({
key,
appKey,
key: key,
appKey: appKey,
connectionId: connectionId,
parameters: parameters,
status: 'incomplete',
@@ -345,16 +367,6 @@ class Step extends Base {
return updatedStep;
}
async $afterInsert(queryContext) {
await super.$afterInsert(queryContext);
Telemetry.stepCreated(this);
}
async $afterUpdate(opt, queryContext) {
await super.$afterUpdate(opt, queryContext);
Telemetry.stepUpdated(this);
}
}
export default Step;

View File

@@ -1,504 +0,0 @@
import { beforeEach, describe, it, expect, vi } from 'vitest';
import appConfig from '../config/app.js';
import App from './app.js';
import Base from './base.js';
import Step from './step.js';
import Flow from './flow.js';
import Connection from './connection.js';
import ExecutionStep from './execution-step.js';
import Telemetry from '../helpers/telemetry/index.js';
import * as testRunModule from '../services/test-run.js';
import { createFlow } from '../../test/factories/flow.js';
import { createUser } from '../../test/factories/user.js';
import { createRole } from '../../test/factories/role.js';
import { createPermission } from '../../test/factories/permission.js';
import { createConnection } from '../../test/factories/connection.js';
import { createStep } from '../../test/factories/step.js';
import { createExecutionStep } from '../../test/factories/execution-step.js';
describe('Step model', () => {
it('tableName should return correct name', () => {
expect(Step.tableName).toBe('steps');
});
it('jsonSchema should have correct validations', () => {
expect(Step.jsonSchema).toMatchSnapshot();
});
it('virtualAttributes should return correct attributes', () => {
const virtualAttributes = Step.virtualAttributes;
const expectedAttributes = ['iconUrl', 'webhookUrl'];
expect(virtualAttributes).toStrictEqual(expectedAttributes);
});
describe('relationMappings', () => {
it('should return correct associations', () => {
const relationMappings = Step.relationMappings();
const expectedRelations = {
flow: {
relation: Base.BelongsToOneRelation,
modelClass: Flow,
join: {
from: 'steps.flow_id',
to: 'flows.id',
},
},
connection: {
relation: Base.HasOneRelation,
modelClass: Connection,
join: {
from: 'steps.connection_id',
to: 'connections.id',
},
},
lastExecutionStep: {
relation: Base.HasOneRelation,
modelClass: ExecutionStep,
join: {
from: 'steps.id',
to: 'execution_steps.step_id',
},
filter: expect.any(Function),
},
executionSteps: {
relation: Base.HasManyRelation,
modelClass: ExecutionStep,
join: {
from: 'steps.id',
to: 'execution_steps.step_id',
},
},
};
expect(relationMappings).toStrictEqual(expectedRelations);
});
it('lastExecutionStep should return the trigger step', () => {
const relations = Step.relationMappings();
const firstSpy = vi.fn();
const limitSpy = vi.fn().mockImplementation(() => ({
first: firstSpy,
}));
const orderBySpy = vi.fn().mockImplementation(() => ({
limit: limitSpy,
}));
relations.lastExecutionStep.filter({ orderBy: orderBySpy });
expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc');
expect(limitSpy).toHaveBeenCalledWith(1);
expect(firstSpy).toHaveBeenCalledOnce();
});
});
describe('webhookUrl', () => {
it('should return it along with appConfig.webhookUrl when exists', () => {
vi.spyOn(appConfig, 'webhookUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
const step = new Step();
step.webhookPath = '/webhook-path';
expect(step.webhookUrl).toBe('https://automatisch.io/webhook-path');
});
it('should return null when webhookUrl does not exist', () => {
const step = new Step();
expect(step.webhookUrl).toBe(null);
});
});
describe('iconUrl', () => {
it('should return step app icon absolute URL when app is set', () => {
vi.spyOn(appConfig, 'baseUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
const step = new Step();
step.appKey = 'gitlab';
expect(step.iconUrl).toBe(
'https://automatisch.io/apps/gitlab/assets/favicon.svg'
);
});
it('should return null when appKey is not set', () => {
const step = new Step();
expect(step.iconUrl).toBe(null);
});
});
it('isTrigger should return true when step type is trigger', () => {
const step = new Step();
step.type = 'trigger';
expect(step.isTrigger).toBe(true);
});
it('isAction should return true when step type is action', () => {
const step = new Step();
step.type = 'action';
expect(step.isAction).toBe(true);
});
describe.todo('computeWebhookPath');
describe('getWebhookUrl', () => {
it('should return absolute webhook URL when step type is trigger', async () => {
const step = new Step();
step.type = 'trigger';
vi.spyOn(step, 'computeWebhookPath').mockResolvedValue('/webhook-path');
vi.spyOn(appConfig, 'webhookUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
expect(await step.getWebhookUrl()).toBe(
'https://automatisch.io/webhook-path'
);
});
it('should return undefined when step type is action', async () => {
const step = new Step();
step.type = 'action';
expect(await step.getWebhookUrl()).toBe(undefined);
});
});
describe('getApp', () => {
it('should return app with the given appKey', async () => {
const step = new Step();
step.appKey = 'gitlab';
const findOneByKeySpy = vi.spyOn(App, 'findOneByKey').mockResolvedValue();
await step.getApp();
expect(findOneByKeySpy).toHaveBeenCalledWith('gitlab');
});
it('should return null with no appKey', async () => {
const step = new Step();
const findOneByKeySpy = vi.spyOn(App, 'findOneByKey').mockResolvedValue();
expect(await step.getApp()).toBe(null);
expect(findOneByKeySpy).not.toHaveBeenCalled();
});
});
it('test should execute the flow and mark the step as completed', async () => {
const step = await createStep({ status: 'incomplete' });
const testRunSpy = vi.spyOn(testRunModule, 'default').mockResolvedValue();
const updatedStep = await step.test();
expect(testRunSpy).toHaveBeenCalledWith({ stepId: step.id });
expect(updatedStep.status).toBe('completed');
});
it('getLastExecutionStep should return last execution step', async () => {
const step = await createStep();
await createExecutionStep({ stepId: step.id });
const secondExecutionStep = await createExecutionStep({ stepId: step.id });
expect(await step.getLastExecutionStep()).toStrictEqual(
secondExecutionStep
);
});
it('getNextStep should return the next step', async () => {
const firstStep = await createStep();
const secondStep = await createStep({ flowId: firstStep.flowId });
const thirdStep = await createStep({ flowId: firstStep.flowId });
expect(await secondStep.getNextStep()).toStrictEqual(thirdStep);
});
describe('getTriggerCommand', () => {
it('should return trigger command when app key and key are defined in trigger step', async () => {
const step = new Step();
step.type = 'trigger';
step.appKey = 'webhook';
step.key = 'catchRawWebhook';
const findOneByKeySpy = vi.spyOn(App, 'findOneByKey');
const triggerCommand = await step.getTriggerCommand();
expect(findOneByKeySpy).toHaveBeenCalledWith(step.appKey);
expect(triggerCommand.key).toBe(step.key);
});
it('should return null when key is not defined', async () => {
const step = new Step();
step.type = 'trigger';
step.appKey = 'webhook';
expect(await step.getTriggerCommand()).toBe(null);
});
});
describe('getActionCommand', () => {
it('should return action comamand when app key and key are defined in action step', async () => {
const step = new Step();
step.type = 'action';
step.appKey = 'ntfy';
step.key = 'sendMessage';
const findOneByKeySpy = vi.spyOn(App, 'findOneByKey');
const actionCommand = await step.getActionCommand();
expect(findOneByKeySpy).toHaveBeenCalledWith(step.appKey);
expect(actionCommand.key).toBe(step.key);
});
it('should return null when key is not defined', async () => {
const step = new Step();
step.type = 'action';
step.appKey = 'ntfy';
expect(await step.getActionCommand()).toBe(null);
});
});
describe('getSetupFields', () => {
it('should return trigger setup substep fields in trigger step', async () => {
const step = new Step();
step.appKey = 'webhook';
step.key = 'catchRawWebhook';
step.type = 'trigger';
expect(await step.getSetupFields()).toStrictEqual([
{
label: 'Wait until flow is done',
key: 'workSynchronously',
type: 'dropdown',
required: true,
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
]);
});
it('should return action setup substep fields in action step', async () => {
const step = new Step();
step.appKey = 'datastore';
step.key = 'getValue';
step.type = 'action';
expect(await step.getSetupFields()).toStrictEqual([
{
label: 'Key',
key: 'key',
type: 'string',
required: true,
description: 'The key of your value to get.',
variables: true,
},
]);
});
});
it.todo('getSetupAndDynamicFields');
it.todo('createDynamicFields');
it.todo('createDynamicData');
it.todo('updateWebhookUrl');
describe('delete', () => {
it('should delete the step and align the positions', async () => {
const flow = await createFlow();
await createStep({ flowId: flow.id, position: 1, type: 'trigger' });
await createStep({ flowId: flow.id, position: 2 });
const stepToDelete = await createStep({ flowId: flow.id, position: 3 });
await createStep({ flowId: flow.id, position: 4 });
await stepToDelete.delete();
const steps = await flow.$relatedQuery('steps');
const stepIds = steps.map((step) => step.id);
expect(stepIds).not.toContain(stepToDelete.id);
});
it('should align the positions of remaining steps', async () => {
const flow = await createFlow();
await createStep({ flowId: flow.id, position: 1, type: 'trigger' });
await createStep({ flowId: flow.id, position: 2 });
const stepToDelete = await createStep({ flowId: flow.id, position: 3 });
await createStep({ flowId: flow.id, position: 4 });
await stepToDelete.delete();
const steps = await flow.$relatedQuery('steps');
const stepPositions = steps.map((step) => step.position);
expect(stepPositions).toMatchObject([1, 2, 3]);
});
it('should delete related execution steps', async () => {
const step = await createStep();
const executionStep = await createExecutionStep({ stepId: step.id });
await step.delete();
expect(await executionStep.$query()).toBe(undefined);
});
});
describe('updateFor', async () => {
let step,
userRole,
user,
userConnection,
anotherUser,
anotherUserConnection;
beforeEach(async () => {
userRole = await createRole({ name: 'User' });
anotherUser = await createUser({ roleId: userRole.id });
user = await createUser({ roleId: userRole.id });
userConnection = await createConnection({
key: 'deepl',
userId: user.id,
});
anotherUserConnection = await createConnection({
key: 'deepl',
userId: anotherUser.id,
});
await createPermission({
roleId: userRole.id,
action: 'read',
subject: 'Connection',
conditions: ['isCreator'],
});
step = await createStep();
});
it('should update step with the given payload and mark it as incomplete', async () => {
const stepData = {
appKey: 'deepl',
key: 'translateText',
connectionId: anotherUserConnection.id,
parameters: {
key: 'value',
},
};
const anotherUserWithRoleAndPermissions = await anotherUser
.$query()
.withGraphFetched({ permissions: true, role: true });
const updatedStep = await step.updateFor(
anotherUserWithRoleAndPermissions,
stepData
);
expect(updatedStep).toMatchObject({
...stepData,
status: 'incomplete',
});
});
it('should invoke updateWebhookUrl', async () => {
const updateWebhookUrlSpy = vi
.spyOn(Step.prototype, 'updateWebhookUrl')
.mockResolvedValue();
const stepData = {
appKey: 'deepl',
key: 'translateText',
};
await step.updateFor(user, stepData);
expect(updateWebhookUrlSpy).toHaveBeenCalledOnce();
});
it('should not update step when inaccessible connection is given', async () => {
const stepData = {
appKey: 'deepl',
key: 'translateText',
connectionId: userConnection.id,
};
const anotherUserWithRoleAndPermissions = await anotherUser
.$query()
.withGraphFetched({ permissions: true, role: true });
await expect(() =>
step.updateFor(anotherUserWithRoleAndPermissions, stepData)
).rejects.toThrowError('NotFoundError');
});
it('should not update step when given app key and key do not exist', async () => {
const stepData = {
appKey: 'deepl',
key: 'not-existing-key',
};
await expect(() => step.updateFor(user, stepData)).rejects.toThrowError(
'DeepL does not have an action with the "not-existing-key" key!'
);
});
});
describe('$afterInsert', () => {
it('should call super.$afterInsert', async () => {
const superAfterInsertSpy = vi.spyOn(Base.prototype, '$afterInsert');
await createStep();
expect(superAfterInsertSpy).toHaveBeenCalled();
});
it('should call Telemetry.stepCreated', async () => {
const telemetryStepCreatedSpy = vi
.spyOn(Telemetry, 'stepCreated')
.mockImplementation(() => {});
const step = await createStep();
expect(telemetryStepCreatedSpy).toHaveBeenCalledWith(step);
});
});
describe('$afterUpdate', () => {
it('should call super.$afterUpdate', async () => {
const superAfterUpdateSpy = vi.spyOn(Base.prototype, '$afterUpdate');
const step = await createStep();
await step.$query().patch({ position: 2 });
expect(superAfterUpdateSpy).toHaveBeenCalledOnce();
});
it('$afterUpdate should call Telemetry.stepUpdated', async () => {
const telemetryStepUpdatedSpy = vi
.spyOn(Telemetry, 'stepUpdated')
.mockImplementation(() => {});
const step = await createStep();
await step.$query().patch({ position: 2 });
expect(telemetryStepUpdatedSpy).toHaveBeenCalled({});
});
});
});

View File

@@ -212,10 +212,6 @@ class User extends Base {
return `${appConfig.webAppUrl}/accept-invitation?token=${this.invitationToken}`;
}
get ability() {
return userAbility(this);
}
static async authenticate(email, password) {
const user = await User.query().findOne({
email: email?.toLowerCase() || null,
@@ -227,8 +223,8 @@ class User extends Base {
}
}
async login(password) {
return await bcrypt.compare(password, this.password);
login(password) {
return bcrypt.compare(password, this.password);
}
async generateResetPasswordToken() {
@@ -411,7 +407,7 @@ class User extends Base {
}
}
startTrialPeriod() {
async startTrialPeriod() {
this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate();
}
@@ -587,30 +583,32 @@ class User extends Base {
return user;
}
can(action, subject) {
const can = this.ability.can(action, subject);
async $beforeInsert(queryContext) {
await super.$beforeInsert(queryContext);
if (!can) throw new NotAuthorizedError('The user is not authorized!');
this.email = this.email.toLowerCase();
await this.generateHash();
const relevantRule = this.ability.relevantRuleFor(action, subject);
const conditions = relevantRule?.conditions || [];
const conditionMap = Object.fromEntries(
conditions.map((condition) => [condition, true])
);
return conditionMap;
}
lowercaseEmail() {
if (this.email) {
this.email = this.email.toLowerCase();
if (appConfig.isCloud) {
await this.startTrialPeriod();
}
}
async createUsageData() {
async $beforeUpdate(opt, queryContext) {
await super.$beforeUpdate(opt, queryContext);
if (this.email) {
this.email = this.email.toLowerCase();
}
await this.generateHash();
}
async $afterInsert(queryContext) {
await super.$afterInsert(queryContext);
if (appConfig.isCloud) {
return await this.$relatedQuery('usageData').insertAndFetch({
await this.$relatedQuery('usageData').insert({
userId: this.id,
consumedTaskCount: 0,
nextResetAt: DateTime.now().plus({ days: 30 }).toISODate(),
@@ -618,10 +616,8 @@ class User extends Base {
}
}
async omitEnterprisePermissionsWithoutValidLicense() {
if (await hasValidLicense()) {
return this;
}
async $afterFind() {
if (await hasValidLicense()) return this;
if (Array.isArray(this.permissions)) {
this.permissions = this.permissions.filter((permission) => {
@@ -635,35 +631,35 @@ class User extends Base {
return !restrictedSubjects.includes(permission.subject);
});
}
return this;
}
async $beforeInsert(queryContext) {
await super.$beforeInsert(queryContext);
this.lowercaseEmail();
await this.generateHash();
if (appConfig.isCloud) {
this.startTrialPeriod();
}
get ability() {
return userAbility(this);
}
async $beforeUpdate(opt, queryContext) {
await super.$beforeUpdate(opt, queryContext);
can(action, subject) {
const can = this.ability.can(action, subject);
this.lowercaseEmail();
if (!can) throw new NotAuthorizedError();
await this.generateHash();
const relevantRule = this.ability.relevantRuleFor(action, subject);
const conditions = relevantRule?.conditions || [];
const conditionMap = Object.fromEntries(
conditions.map((condition) => [condition, true])
);
return conditionMap;
}
async $afterInsert(queryContext) {
await super.$afterInsert(queryContext);
cannot(action, subject) {
const cannot = this.ability.cannot(action, subject);
await this.createUsageData();
}
if (cannot) throw new NotAuthorizedError();
async $afterFind() {
await this.omitEnterprisePermissionsWithoutValidLicense();
return cannot;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,31 @@
import { generateQueue } from './queue.js';
import process from 'process';
import { Queue } from 'bullmq';
import redisConfig from '../config/redis.js';
import logger from '../helpers/logger.js';
const CONNECTION_REFUSED = 'ECONNREFUSED';
const redisConnection = {
connection: redisConfig,
};
const actionQueue = new Queue('action', redisConnection);
process.on('SIGTERM', async () => {
await actionQueue.close();
});
actionQueue.on('error', (error) => {
if (error.code === CONNECTION_REFUSED) {
logger.error(
'Make sure you have installed Redis and it is running.',
error
);
process.exit();
}
logger.error('Error happened in action queue!', error);
});
const actionQueue = generateQueue('action');
export default actionQueue;

View File

@@ -1,4 +1,31 @@
import { generateQueue } from './queue.js';
import process from 'process';
import { Queue } from 'bullmq';
import redisConfig from '../config/redis.js';
import logger from '../helpers/logger.js';
const CONNECTION_REFUSED = 'ECONNREFUSED';
const redisConnection = {
connection: redisConfig,
};
const deleteUserQueue = new Queue('delete-user', redisConnection);
process.on('SIGTERM', async () => {
await deleteUserQueue.close();
});
deleteUserQueue.on('error', (error) => {
if (error.code === CONNECTION_REFUSED) {
logger.error(
'Make sure you have installed Redis and it is running.',
error
);
process.exit();
}
logger.error('Error happened in delete user queue!', error);
});
const deleteUserQueue = generateQueue('delete-user');
export default deleteUserQueue;

View File

@@ -1,4 +1,31 @@
import { generateQueue } from './queue.js';
import process from 'process';
import { Queue } from 'bullmq';
import redisConfig from '../config/redis.js';
import logger from '../helpers/logger.js';
const CONNECTION_REFUSED = 'ECONNREFUSED';
const redisConnection = {
connection: redisConfig,
};
const emailQueue = new Queue('email', redisConnection);
process.on('SIGTERM', async () => {
await emailQueue.close();
});
emailQueue.on('error', (error) => {
if (error.code === CONNECTION_REFUSED) {
logger.error(
'Make sure you have installed Redis and it is running.',
error
);
process.exit();
}
logger.error('Error happened in email queue!', error);
});
const emailQueue = generateQueue('email');
export default emailQueue;

View File

@@ -1,4 +1,31 @@
import { generateQueue } from './queue.js';
import process from 'process';
import { Queue } from 'bullmq';
import redisConfig from '../config/redis.js';
import logger from '../helpers/logger.js';
const CONNECTION_REFUSED = 'ECONNREFUSED';
const redisConnection = {
connection: redisConfig,
};
const flowQueue = new Queue('flow', redisConnection);
process.on('SIGTERM', async () => {
await flowQueue.close();
});
flowQueue.on('error', (error) => {
if (error.code === CONNECTION_REFUSED) {
logger.error(
'Make sure you have installed Redis and it is running.',
error
);
process.exit();
}
logger.error('Error happened in flow queue!', error);
});
const flowQueue = generateQueue('flow');
export default flowQueue;

View File

@@ -1,21 +0,0 @@
import appConfig from '../config/app.js';
import actionQueue from './action.js';
import emailQueue from './email.js';
import flowQueue from './flow.js';
import triggerQueue from './trigger.js';
import deleteUserQueue from './delete-user.ee.js';
import removeCancelledSubscriptionsQueue from './remove-cancelled-subscriptions.ee.js';
const queues = [
actionQueue,
emailQueue,
flowQueue,
triggerQueue,
deleteUserQueue,
];
if (appConfig.isCloud) {
queues.push(removeCancelledSubscriptionsQueue);
}
export default queues;

View File

@@ -1,44 +0,0 @@
import process from 'process';
import { Queue } from 'bullmq';
import redisConfig from '../config/redis.js';
import logger from '../helpers/logger.js';
const CONNECTION_REFUSED = 'ECONNREFUSED';
const redisConnection = {
connection: redisConfig,
};
export const generateQueue = (queueName, options) => {
const queue = new Queue(queueName, redisConnection);
queue.on('error', (error) => queueOnError(error, queueName));
if (options?.runDaily) addScheduler(queueName, queue);
return queue;
};
const queueOnError = (error, queueName) => {
if (error.code === CONNECTION_REFUSED) {
const errorMessage =
'Make sure you have installed Redis and it is running.';
logger.error(errorMessage, error);
process.exit();
}
logger.error(`Error happened in ${queueName} queue!`, error);
};
const addScheduler = (queueName, queue) => {
const everydayAtOneOclock = '0 1 * * *';
queue.add(queueName, null, {
jobId: queueName,
repeat: {
pattern: everydayAtOneOclock,
},
});
};

View File

@@ -1,8 +1,44 @@
import { generateQueue } from './queue.js';
import process from 'process';
import { Queue } from 'bullmq';
import redisConfig from '../config/redis.js';
import logger from '../helpers/logger.js';
const removeCancelledSubscriptionsQueue = generateQueue(
const CONNECTION_REFUSED = 'ECONNREFUSED';
const redisConnection = {
connection: redisConfig,
};
const removeCancelledSubscriptionsQueue = new Queue(
'remove-cancelled-subscriptions',
{ runDaily: true }
redisConnection
);
process.on('SIGTERM', async () => {
await removeCancelledSubscriptionsQueue.close();
});
removeCancelledSubscriptionsQueue.on('error', (error) => {
if (error.code === CONNECTION_REFUSED) {
logger.error(
'Make sure you have installed Redis and it is running.',
error
);
process.exit();
}
logger.error(
'Error happened in remove cancelled subscriptions queue!',
error
);
});
removeCancelledSubscriptionsQueue.add('remove-cancelled-subscriptions', null, {
jobId: 'remove-cancelled-subscriptions',
repeat: {
pattern: '0 1 * * *',
},
});
export default removeCancelledSubscriptionsQueue;

View File

@@ -1,4 +1,31 @@
import { generateQueue } from './queue.js';
import process from 'process';
import { Queue } from 'bullmq';
import redisConfig from '../config/redis.js';
import logger from '../helpers/logger.js';
const CONNECTION_REFUSED = 'ECONNREFUSED';
const redisConnection = {
connection: redisConfig,
};
const triggerQueue = new Queue('trigger', redisConnection);
process.on('SIGTERM', async () => {
await triggerQueue.close();
});
triggerQueue.on('error', (error) => {
if (error.code === CONNECTION_REFUSED) {
logger.error(
'Make sure you have installed Redis and it is running.',
error
);
process.exit();
}
logger.error('Error happened in trigger queue!', error);
});
const triggerQueue = generateQueue('trigger');
export default triggerQueue;

View File

@@ -26,7 +26,7 @@ const serializers = {
Permission: permissionSerializer,
AdminSamlAuthProvider: adminSamlAuthProviderSerializer,
SamlAuthProvider: samlAuthProviderSerializer,
RoleMapping: samlAuthProviderRoleMappingSerializer,
SamlAuthProvidersRoleMapping: samlAuthProviderRoleMappingSerializer,
AppAuthClient: appAuthClientSerializer,
AppConfig: appConfigSerializer,
Flow: flowSerializer,

View File

@@ -1,22 +1,20 @@
import * as Sentry from './helpers/sentry.ee.js';
import process from 'node:process';
import appConfig from './config/app.js';
Sentry.init();
import './config/orm.js';
import './helpers/check-worker-readiness.js';
import queues from './queues/index.js';
import workers from './workers/index.js';
import './workers/flow.js';
import './workers/trigger.js';
import './workers/action.js';
import './workers/email.js';
import './workers/delete-user.ee.js';
process.on('SIGTERM', async () => {
for (const queue of queues) {
await queue.close();
}
for (const worker of workers) {
await worker.close();
}
});
if (appConfig.isCloud) {
import('./workers/remove-cancelled-subscriptions.ee.js');
import('./queues/remove-cancelled-subscriptions.ee.js');
}
import telemetry from './helpers/telemetry/index.js';

View File

@@ -1,4 +1,5 @@
import { Worker } from 'bullmq';
import process from 'node:process';
import * as Sentry from '../helpers/sentry.ee.js';
import redisConfig from '../config/redis.js';
@@ -14,7 +15,7 @@ import delayAsMilliseconds from '../helpers/delay-as-milliseconds.js';
const DEFAULT_DELAY_DURATION = 0;
const actionWorker = new Worker(
export const worker = new Worker(
'action',
async (job) => {
const { stepId, flowId, executionId, computedParameters, executionStep } =
@@ -54,11 +55,11 @@ const actionWorker = new Worker(
{ connection: redisConfig }
);
actionWorker.on('completed', (job) => {
worker.on('completed', (job) => {
logger.info(`JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has started!`);
});
actionWorker.on('failed', (job, err) => {
worker.on('failed', (job, err) => {
const errorMessage = `
JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}
\n ${err.stack}
@@ -73,4 +74,6 @@ actionWorker.on('failed', (job, err) => {
});
});
export default actionWorker;
process.on('SIGTERM', async () => {
await worker.close();
});

View File

@@ -1,4 +1,5 @@
import { Worker } from 'bullmq';
import process from 'node:process';
import * as Sentry from '../helpers/sentry.ee.js';
import redisConfig from '../config/redis.js';
@@ -7,7 +8,7 @@ import appConfig from '../config/app.js';
import User from '../models/user.js';
import ExecutionStep from '../models/execution-step.js';
const deleteUserWorker = new Worker(
export const worker = new Worker(
'delete-user',
async (job) => {
const { id } = job.data;
@@ -45,13 +46,13 @@ const deleteUserWorker = new Worker(
{ connection: redisConfig }
);
deleteUserWorker.on('completed', (job) => {
worker.on('completed', (job) => {
logger.info(
`JOB ID: ${job.id} - The user with the ID of '${job.data.id}' has been deleted!`
);
});
deleteUserWorker.on('failed', (job, err) => {
worker.on('failed', (job, err) => {
const errorMessage = `
JOB ID: ${job.id} - The user with the ID of '${job.data.id}' has failed to be deleted! ${err.message}
\n ${err.stack}
@@ -66,4 +67,6 @@ deleteUserWorker.on('failed', (job, err) => {
});
});
export default deleteUserWorker;
process.on('SIGTERM', async () => {
await worker.close();
});

View File

@@ -1,4 +1,5 @@
import { Worker } from 'bullmq';
import process from 'node:process';
import * as Sentry from '../helpers/sentry.ee.js';
import redisConfig from '../config/redis.js';
@@ -15,7 +16,7 @@ const isAutomatischEmail = (email) => {
return email.endsWith('@automatisch.io');
};
const emailWorker = new Worker(
export const worker = new Worker(
'email',
async (job) => {
const { email, subject, template, params } = job.data;
@@ -38,13 +39,13 @@ const emailWorker = new Worker(
{ connection: redisConfig }
);
emailWorker.on('completed', (job) => {
worker.on('completed', (job) => {
logger.info(
`JOB ID: ${job.id} - ${job.data.subject} email sent to ${job.data.email}!`
);
});
emailWorker.on('failed', (job, err) => {
worker.on('failed', (job, err) => {
const errorMessage = `
JOB ID: ${job.id} - ${job.data.subject} email to ${job.data.email} has failed to send with ${err.message}
\n ${err.stack}
@@ -59,4 +60,6 @@ emailWorker.on('failed', (job, err) => {
});
});
export default emailWorker;
process.on('SIGTERM', async () => {
await worker.close();
});

View File

@@ -1,4 +1,5 @@
import { Worker } from 'bullmq';
import process from 'node:process';
import * as Sentry from '../helpers/sentry.ee.js';
import redisConfig from '../config/redis.js';
@@ -12,7 +13,7 @@ import {
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
} from '../helpers/remove-job-configuration.js';
const flowWorker = new Worker(
export const worker = new Worker(
'flow',
async (job) => {
const { flowId } = job.data;
@@ -63,11 +64,11 @@ const flowWorker = new Worker(
{ connection: redisConfig }
);
flowWorker.on('completed', (job) => {
worker.on('completed', (job) => {
logger.info(`JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has started!`);
});
flowWorker.on('failed', async (job, err) => {
worker.on('failed', async (job, err) => {
const errorMessage = `
JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}
\n ${err.stack}
@@ -94,4 +95,6 @@ flowWorker.on('failed', async (job, err) => {
});
});
export default flowWorker;
process.on('SIGTERM', async () => {
await worker.close();
});

View File

@@ -1,21 +0,0 @@
import appConfig from '../config/app.js';
import actionWorker from './action.js';
import emailWorker from './email.js';
import flowWorker from './flow.js';
import triggerWorker from './trigger.js';
import deleteUserWorker from './delete-user.ee.js';
import removeCancelledSubscriptionsWorker from './remove-cancelled-subscriptions.ee.js';
const workers = [
actionWorker,
emailWorker,
flowWorker,
triggerWorker,
deleteUserWorker,
];
if (appConfig.isCloud) {
workers.push(removeCancelledSubscriptionsWorker);
}
export default workers;

View File

@@ -1,11 +1,12 @@
import { Worker } from 'bullmq';
import process from 'node:process';
import { DateTime } from 'luxon';
import * as Sentry from '../helpers/sentry.ee.js';
import redisConfig from '../config/redis.js';
import logger from '../helpers/logger.js';
import Subscription from '../models/subscription.ee.js';
const removeCancelledSubscriptionsWorker = new Worker(
export const worker = new Worker(
'remove-cancelled-subscriptions',
async () => {
await Subscription.query()
@@ -22,13 +23,13 @@ const removeCancelledSubscriptionsWorker = new Worker(
{ connection: redisConfig }
);
removeCancelledSubscriptionsWorker.on('completed', (job) => {
worker.on('completed', (job) => {
logger.info(
`JOB ID: ${job.id} - The cancelled subscriptions have been removed!`
);
});
removeCancelledSubscriptionsWorker.on('failed', (job, err) => {
worker.on('failed', (job, err) => {
const errorMessage = `
JOB ID: ${job.id} - ERROR: The cancelled subscriptions can not be removed! ${err.message}
\n ${err.stack}
@@ -41,4 +42,6 @@ removeCancelledSubscriptionsWorker.on('failed', (job, err) => {
});
});
export default removeCancelledSubscriptionsWorker;
process.on('SIGTERM', async () => {
await worker.close();
});

View File

@@ -1,4 +1,5 @@
import { Worker } from 'bullmq';
import process from 'node:process';
import * as Sentry from '../helpers/sentry.ee.js';
import redisConfig from '../config/redis.js';
@@ -11,7 +12,7 @@ import {
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
} from '../helpers/remove-job-configuration.js';
const triggerWorker = new Worker(
export const worker = new Worker(
'trigger',
async (job) => {
const { flowId, executionId, stepId, executionStep } = await processTrigger(
@@ -40,11 +41,11 @@ const triggerWorker = new Worker(
{ connection: redisConfig }
);
triggerWorker.on('completed', (job) => {
worker.on('completed', (job) => {
logger.info(`JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has started!`);
});
triggerWorker.on('failed', (job, err) => {
worker.on('failed', (job, err) => {
const errorMessage = `
JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}
\n ${err.stack}
@@ -59,4 +60,6 @@ triggerWorker.on('failed', (job, err) => {
});
});
export default triggerWorker;
process.on('SIGTERM', async () => {
await worker.close();
});

View File

@@ -1,15 +1,16 @@
import { faker } from '@faker-js/faker';
import { createRole } from './role.js';
import RoleMapping from '../../src/models/role-mapping.ee.js';
import { createSamlAuthProvider } from './saml-auth-provider.ee.js';
import SamlAuthProviderRoleMapping from '../../src/models/saml-auth-providers-role-mapping.ee.js';
export const createRoleMapping = async (params = {}) => {
params.roleId = params.roleId || (await createRole()).id;
params.roleId = params?.roleId || (await createRole()).id;
params.samlAuthProviderId =
params.samlAuthProviderId || (await createSamlAuthProvider()).id;
params.remoteRoleName = params.remoteRoleName || faker.person.jobType();
params?.samlAuthProviderId || (await createSamlAuthProvider()).id;
const roleMapping = await RoleMapping.query().insertAndFetch(params);
params.remoteRoleName = params?.remoteRoleName || 'User';
return roleMapping;
const samlAuthProviderRoleMapping =
await SamlAuthProviderRoleMapping.query().insertAndFetch(params);
return samlAuthProviderRoleMapping;
};

View File

@@ -1,5 +1,5 @@
import { faker } from '@faker-js/faker';
import Role from '../../src/models/role.js';
import Role from '../../src/models/role';
export const createRole = async (params = {}) => {
const name = faker.lorem.word();

View File

@@ -0,0 +1,16 @@
import { faker } from '@faker-js/faker';
import { createRole } from './role.js';
import SamlAuthProvidersRoleMapping from '../../src/models/saml-auth-providers-role-mapping.ee.js';
import { createSamlAuthProvider } from './saml-auth-provider.ee.js';
export const createSamlAuthProvidersRoleMapping = async (params = {}) => {
params.roleId = params.roleId || (await createRole()).id;
params.samlAuthProviderId =
params.samlAuthProviderId || (await createSamlAuthProvider()).id;
params.remoteRoleName = params.remoteRoleName || faker.person.jobType();
const samlAuthProvider =
await SamlAuthProvidersRoleMapping.query().insertAndFetch(params);
return samlAuthProvider;
};

View File

@@ -15,7 +15,7 @@ const getRoleMappingsMock = async (roleMappings) => {
currentPage: null,
isArray: true,
totalPages: null,
type: 'RoleMapping',
type: 'SamlAuthProvidersRoleMapping',
},
};
};

View File

@@ -15,7 +15,7 @@ const createRoleMappingsMock = async (roleMappings) => {
currentPage: null,
isArray: true,
totalPages: null,
type: 'RoleMapping',
type: 'SamlAuthProvidersRoleMapping',
},
};
};

View File

@@ -6,6 +6,18 @@ const createFlowMock = async (flow) => {
status: flow.status,
createdAt: flow.createdAt.getTime(),
updatedAt: flow.updatedAt.getTime(),
steps: [
{
position: 1,
status: 'incomplete',
type: 'trigger',
},
{
position: 2,
status: 'incomplete',
type: 'action',
},
],
};
return {

View File

@@ -2,25 +2,8 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
root: './',
environment: 'node',
setupFiles: ['./test/setup/global-hooks.js'],
globals: true,
reporters: process.env.GITHUB_ACTIONS ? ['dot', 'github-actions'] : ['dot'],
coverage: {
reportOnFailure: true,
provider: 'v8',
reportsDirectory: './coverage',
reporter: ['text', 'lcov'],
all: true,
include: ['**/src/models/**', '**/src/controllers/**'],
thresholds: {
autoUpdate: true,
statements: 93.41,
branches: 93.46,
functions: 95.95,
lines: 93.41,
},
},
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
pages/.vitepress/cache

View File

@@ -4,7 +4,6 @@
"license": "See LICENSE file",
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
"private": true,
"type": "module",
"scripts": {
"dev": "vitepress dev pages --port 3002",
"build": "vitepress build pages",

View File

@@ -6,19 +6,11 @@ Clone main branch of Automatisch.
git clone git@github.com:automatisch/automatisch.git
```
Then, install the dependencies for both backend and web packages separately.
Then, install the dependencies.
```bash
cd automatisch
# Install backend dependencies
cd packages/backend
yarn install
# Install web dependencies
cd packages/web
yarn install
```
## Backend
@@ -61,14 +53,12 @@ yarn db:seed:user
Start the main backend server.
```bash
cd packages/backend
yarn dev
```
Start the worker server in another terminal tab.
```bash
cd packages/backend
yarn worker
```
@@ -94,7 +84,6 @@ It will automatically open [http://localhost:3001](http://localhost:3001) in you
```bash
cd packages/docs
yarn install
yarn dev
```

View File

@@ -1,6 +1,6 @@
# Repository Structure
We manage a monorepo structure with the following packages:
We use `lerna` with `yarn workspaces` to manage the mono repository. We have the following packages:
```
.
@@ -15,5 +15,3 @@ We manage a monorepo structure with the following packages:
- `docs` - The docs package contains the documentation website.
- `e2e-tests` - The e2e-tests package contains the end-to-end tests for the internal usage.
- `web` - The web package contains the frontend application of Automatisch.
Each package is independently managed, and has its own package.json file to manage dependencies. This allows for better isolation and flexibility.

File diff suppressed because it is too large Load Diff

View File

@@ -29,12 +29,10 @@
"@playwright/test": "^1.45.1"
},
"dependencies": {
"axios": "^1.6.0",
"dotenv": "^16.3.1",
"eslint": "^8.13.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"knex": "^2.4.0",
"luxon": "^3.4.4",
"micro": "^10.0.1",
"pg": "^8.12.0",

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,6 @@
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"axios": "^1.6.0",
"clipboard-copy": "^4.0.1",
"compare-versions": "^4.1.3",
"lodash": "^4.17.21",

View File

@@ -112,7 +112,7 @@ export default function ResetPasswordForm() {
<Alert
data-test="accept-invitation-form-error"
severity="error"
sx={{ mt: 1 }}
sx={{ mt: 1, fontWeight: 500 }}
>
{formatMessage('acceptInvitationForm.invalidToken')}
</Alert>

View File

@@ -126,7 +126,7 @@ function AddAppConnection(props) {
</DialogTitle>
{authDocUrl && (
<Alert severity="info">
<Alert severity="info" sx={{ fontWeight: 300 }}>
{formatMessage('addAppConnection.callToDocs', {
appName: name,
docsLink: generateExternalLink(authDocUrl),
@@ -138,7 +138,7 @@ function AddAppConnection(props) {
<Alert
data-test="add-connection-error"
severity="error"
sx={{ mt: 1, wordBreak: 'break-all' }}
sx={{ mt: 1, fontWeight: 500, wordBreak: 'break-all' }}
>
{!errorDetails && errorMessage}
{errorDetails && (

View File

@@ -32,7 +32,10 @@ function AdminApplicationAuthClientDialog(props) {
<Dialog open={true} onClose={onClose}>
<DialogTitle>{title}</DialogTitle>
{error && (
<Alert severity="error" sx={{ mt: 1, wordBreak: 'break-all' }}>
<Alert
severity="error"
sx={{ mt: 1, fontWeight: 500, wordBreak: 'break-all' }}
>
{error.message}
</Alert>
)}

View File

@@ -29,8 +29,6 @@ function ControlledAutocomplete(props) {
options = [],
dependsOn = [],
showOptionValue,
renderInput,
showHelperText = true,
...autocompleteProps
} = props;
let dependsOnValues = [];
@@ -107,18 +105,16 @@ function ControlledAutocomplete(props) {
)}
</li>
)}
renderInput={(params) => renderInput(params, fieldState)}
/>
{showHelperText && (
<FormHelperText
variant="outlined"
error={Boolean(fieldState.isTouched && fieldState.error)}
>
{fieldState.isTouched
? fieldState.error?.message || description
: description}
</FormHelperText>
)}
<FormHelperText
variant="outlined"
error={Boolean(fieldState.isTouched && fieldState.error)}
>
{fieldState.isTouched
? fieldState.error?.message || description
: description}
</FormHelperText>
</div>
)}
/>
@@ -136,8 +132,6 @@ ControlledAutocomplete.propTypes = {
onBlur: PropTypes.func,
onChange: PropTypes.func,
options: PropTypes.array,
renderInput: PropTypes.func.isRequired,
showHelperText: PropTypes.bool,
};
export default ControlledAutocomplete;

View File

@@ -6,7 +6,7 @@ import FormHelperText from '@mui/material/FormHelperText';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import ClearIcon from '@mui/icons-material/Clear';
import { ActionButtonsWrapper } from './style';
import { ClickAwayListener } from '@mui/base/ClickAwayListener';
import ClickAwayListener from '@mui/base/ClickAwayListener';
import InputLabel from '@mui/material/InputLabel';
import { createEditor } from 'slate';
import { Editable, ReactEditor } from 'slate-react';

View File

@@ -1,8 +1,8 @@
import * as React from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Alert from '@mui/material/Alert';
import LoadingButton from '@mui/lab/LoadingButton';
import { enqueueSnackbar } from 'notistack';
import useForgotPassword from 'hooks/useForgotPassword';
import Form from 'components/Form';
@@ -12,17 +12,25 @@ import useFormatMessage from 'hooks/useFormatMessage';
export default function ForgotPasswordForm() {
const formatMessage = useFormatMessage();
const {
mutate: forgotPassword,
mutateAsync: forgotPassword,
isPending: loading,
isSuccess,
isError,
error,
} = useForgotPassword();
const handleSubmit = ({ email }) => {
forgotPassword({
email,
});
const handleSubmit = async (values) => {
const { email } = values;
try {
await forgotPassword({
email,
});
} catch (error) {
enqueueSnackbar(
error?.message || formatMessage('forgotPasswordForm.error'),
{
variant: 'error',
},
);
}
};
return (
@@ -49,16 +57,6 @@ export default function ForgotPasswordForm() {
margin="dense"
autoComplete="username"
/>
{isError && (
<Alert severity="error" sx={{ mt: 2 }}>
{error?.message || formatMessage('forgotPasswordForm.error')}
</Alert>
)}
{isSuccess && (
<Alert severity="success" sx={{ mt: 2 }}>
{formatMessage('forgotPasswordForm.instructionsSent')}
</Alert>
)}
<LoadingButton
type="submit"
variant="contained"
@@ -70,6 +68,14 @@ export default function ForgotPasswordForm() {
>
{formatMessage('forgotPasswordForm.submit')}
</LoadingButton>
{isSuccess && (
<Typography
variant="body1"
sx={{ color: (theme) => theme.palette.success.main }}
>
{formatMessage('forgotPasswordForm.instructionsSent')}
</Typography>
)}
</Form>
</Paper>
);

View File

@@ -13,14 +13,12 @@ function Form(props) {
resolver,
render,
mode = 'all',
reValidateMode = 'onBlur',
automaticValidation = true,
...formProps
} = props;
const methods = useForm({
defaultValues,
reValidateMode,
reValidateMode: 'onBlur',
resolver,
mode,
});
@@ -32,9 +30,7 @@ function Form(props) {
* For fields having `dependsOn` fields, we need to re-validate the form.
*/
React.useEffect(() => {
if (automaticValidation) {
methods.trigger();
}
methods.trigger();
}, [methods.trigger, form]);
React.useEffect(() => {
@@ -60,8 +56,6 @@ Form.propTypes = {
render: PropTypes.func,
resolver: PropTypes.func,
mode: PropTypes.oneOf(['onChange', 'onBlur', 'onSubmit', 'onTouched', 'all']),
reValidateMode: PropTypes.oneOf(['onChange', 'onBlur', 'onSubmit']),
automaticValidation: PropTypes.bool,
};
export default Form;

View File

@@ -188,7 +188,7 @@ function InstallationForm() {
)}
/>
{install.isSuccess && (
<Alert data-test="success-alert" severity="success" sx={{ mt: 3 }}>
<Alert data-test="success-alert" severity="success" sx={{ mt: 3, fontWeight: 500 }}>
{formatMessage('installationForm.success', {
link: (str) => (
<Link

View File

@@ -2,7 +2,6 @@ import * as React from 'react';
import { useNavigate, Link as RouterLink } from 'react-router-dom';
import Paper from '@mui/material/Paper';
import Link from '@mui/material/Link';
import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography';
import LoadingButton from '@mui/lab/LoadingButton';
import useAuthentication from 'hooks/useAuthentication';
@@ -12,18 +11,16 @@ import Form from 'components/Form';
import TextField from 'components/TextField';
import useFormatMessage from 'hooks/useFormatMessage';
import useCreateAccessToken from 'hooks/useCreateAccessToken';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
function LoginForm() {
const isCloud = useCloud();
const navigate = useNavigate();
const formatMessage = useFormatMessage();
const enqueueSnackbar = useEnqueueSnackbar();
const authentication = useAuthentication();
const {
mutateAsync: createAccessToken,
isPending: loading,
error,
isError,
} = useCreateAccessToken();
const { mutateAsync: createAccessToken, isPending: loading } =
useCreateAccessToken();
React.useEffect(() => {
if (authentication.isAuthenticated) {
@@ -40,19 +37,11 @@ function LoginForm() {
});
const { token } = data;
authentication.updateToken(token);
} catch {}
};
const renderError = () => {
const errors = error?.response?.data?.errors?.general || [
error?.message || formatMessage('loginForm.error'),
];
return errors.map((error) => (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
));
} catch (error) {
enqueueSnackbar(error?.message || formatMessage('loginForm.error'), {
variant: 'error',
});
}
};
return (
@@ -105,8 +94,6 @@ function LoginForm() {
</Link>
)}
{isError && renderError()}
<LoadingButton
type="submit"
variant="contained"

View File

@@ -1,51 +0,0 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import PropTypes from 'prop-types';
import ControlledCheckbox from 'components/ControlledCheckbox';
const ActionField = ({ action, subject, disabled, name, syncIsCreator }) => {
const { formState, resetField } = useFormContext();
const actionDefaultValue =
formState.defaultValues?.[name]?.[subject.key]?.[action.key].value;
const conditionFieldName = `${name}.${subject.key}.${action.key}.conditions.isCreator`;
const conditionFieldTouched =
formState.touchedFields?.[name]?.[subject.key]?.[action.key]?.conditions
?.isCreator === true;
const handleSyncIsCreator = (newValue) => {
if (
syncIsCreator &&
actionDefaultValue === false &&
!conditionFieldTouched
) {
resetField(conditionFieldName, { defaultValue: newValue });
}
};
return (
<ControlledCheckbox
disabled={disabled}
name={`${name}.${subject.key}.${action.key}.value`}
dataTest={`${action.key.toLowerCase()}-checkbox`}
onChange={(e, value) => {
handleSyncIsCreator(value);
}}
/>
);
};
ActionField.propTypes = {
action: PropTypes.shape({
key: PropTypes.string.isRequired,
subjects: PropTypes.arrayOf(PropTypes.string).isRequired,
}),
subject: PropTypes.shape({
key: PropTypes.string.isRequired,
}).isRequired,
disabled: PropTypes.bool,
name: PropTypes.string.isRequired,
syncIsCreator: PropTypes.bool,
};
export default ActionField;

View File

@@ -25,6 +25,7 @@ function PermissionSettings(props) {
subject,
actions,
conditions,
defaultChecked,
} = props;
const formatMessage = useFormatMessage();
const { getValues, resetField } = useFormContext();
@@ -33,7 +34,7 @@ function PermissionSettings(props) {
for (const action of actions) {
for (const condition of conditions) {
const fieldName = `${fieldPrefix}.${action.key}.conditions.${condition.key}`;
resetField(fieldName, { keepTouched: true });
resetField(fieldName);
}
}
onClose();
@@ -44,7 +45,7 @@ function PermissionSettings(props) {
for (const condition of conditions) {
const fieldName = `${fieldPrefix}.${action.key}.conditions.${condition.key}`;
const value = getValues(fieldName);
resetField(fieldName, { defaultValue: value, keepTouched: true });
resetField(fieldName, { defaultValue: value });
}
}
onClose();
@@ -55,7 +56,6 @@ function PermissionSettings(props) {
open={open}
onClose={cancel}
data-test={`${subject}-role-conditions-modal`}
keepMounted
>
<DialogTitle>{formatMessage('permissionSettings.title')}</DialogTitle>
@@ -65,10 +65,10 @@ function PermissionSettings(props) {
<TableHead>
<TableRow>
<TableCell component="th" />
{actions.map((action) => (
<TableCell component="th" key={action.key}>
<Typography
component="div"
variant="subtitle1"
align="center"
sx={{
@@ -89,7 +89,7 @@ function PermissionSettings(props) {
sx={{ '&:last-child td': { border: 0 } }}
>
<TableCell scope="row">
<Typography variant="subtitle2" component="div">
<Typography variant="subtitle2">
{condition.label}
</Typography>
</TableCell>
@@ -99,13 +99,14 @@ function PermissionSettings(props) {
key={`${action.key}.${condition.key}`}
align="center"
>
<Typography variant="subtitle2" component="div">
<Typography variant="subtitle2">
{action.subjects.includes(subject) && (
<ControlledCheckbox
name={`${fieldPrefix}.${action.key}.conditions.${condition.key}`}
dataTest={`${
condition.key
}-${action.key.toLowerCase()}-checkbox`}
defaultValue={defaultChecked}
disabled={
getValues(
`${fieldPrefix}.${action.key}.value`,
@@ -143,6 +144,7 @@ PermissionSettings.propTypes = {
fieldPrefix: PropTypes.string.isRequired,
subject: PropTypes.string.isRequired,
open: PropTypes.bool,
defaultChecked: PropTypes.bool,
actions: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string,

View File

@@ -12,15 +12,15 @@ import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import * as React from 'react';
import ControlledCheckbox from 'components/ControlledCheckbox';
import usePermissionCatalog from 'hooks/usePermissionCatalog.ee';
import PermissionSettings from './PermissionSettings.ee';
import PermissionCatalogFieldLoader from './PermissionCatalogFieldLoader';
import ActionField from './ActionField';
const PermissionCatalogField = ({
name = 'permissions',
disabled = false,
syncIsCreator = false,
defaultChecked = false,
}) => {
const { data, isLoading: isPermissionCatalogLoading } =
usePermissionCatalog();
@@ -39,7 +39,6 @@ const PermissionCatalogField = ({
{permissionCatalog?.actions.map((action) => (
<TableCell component="th" key={action.key}>
<Typography
component="div"
variant="subtitle1"
align="center"
sx={{
@@ -63,23 +62,20 @@ const PermissionCatalogField = ({
data-test={`${subject.key}-permission-row`}
>
<TableCell scope="row">
<Typography variant="subtitle2" component="div">
{subject.label}
</Typography>
<Typography variant="subtitle2">{subject.label}</Typography>
</TableCell>
{permissionCatalog?.actions.map((action) => (
<TableCell key={`${subject.key}.${action.key}`} align="center">
<Typography variant="subtitle2" component="div">
<Typography variant="subtitle2">
{action.subjects.includes(subject.key) && (
<ActionField
action={action}
subject={subject}
<ControlledCheckbox
disabled={disabled}
name={name}
syncIsCreator={syncIsCreator}
name={`${name}.${subject.key}.${action.key}.value`}
dataTest={`${action.key.toLowerCase()}-checkbox`}
/>
)}
{!action.subjects.includes(subject.key) && '-'}
</Typography>
</TableCell>
@@ -104,6 +100,7 @@ const PermissionCatalogField = ({
subject={subject.key}
actions={permissionCatalog?.actions}
conditions={permissionCatalog?.conditions}
defaultChecked={defaultChecked}
/>
</Stack>
</TableCell>
@@ -117,7 +114,7 @@ const PermissionCatalogField = ({
PermissionCatalogField.propTypes = {
name: PropTypes.string,
disabled: PropTypes.bool,
syncIsCreator: PropTypes.bool,
defaultChecked: PropTypes.bool,
};
export default PermissionCatalogField;

View File

@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import { ClickAwayListener } from '@mui/base/ClickAwayListener';
import ClickAwayListener from '@mui/base/ClickAwayListener';
import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel';
import * as React from 'react';

View File

@@ -2,7 +2,6 @@ import { yupResolver } from '@hookform/resolvers/yup';
import LoadingButton from '@mui/lab/LoadingButton';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Alert from '@mui/material/Alert';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import * as React from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
@@ -31,8 +30,6 @@ export default function ResetPasswordForm() {
mutateAsync: resetPassword,
isPending,
isSuccess,
error,
isError,
} = useResetPassword();
const token = searchParams.get('token');
@@ -50,23 +47,14 @@ export default function ResetPasswordForm() {
},
});
navigate(URLS.LOGIN);
} catch {}
};
const renderError = () => {
if (!isError) {
return null;
} catch (error) {
enqueueSnackbar(
error?.message || formatMessage('resetPasswordForm.error'),
{
variant: 'error',
},
);
}
const errors = error?.response?.data?.errors?.general || [
error?.message || formatMessage('resetPasswordForm.error'),
];
return errors.map((error) => (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
));
};
return (
@@ -108,6 +96,7 @@ export default function ResetPasswordForm() {
: ''
}
/>
<TextField
label={formatMessage(
'resetPasswordForm.confirmPasswordFieldLabel',
@@ -128,7 +117,7 @@ export default function ResetPasswordForm() {
: ''
}
/>
{renderError()}
<LoadingButton
type="submit"
variant="contained"

View File

@@ -7,7 +7,7 @@ import FormControl from '@mui/material/FormControl';
import SearchIcon from '@mui/icons-material/Search';
import useFormatMessage from 'hooks/useFormatMessage';
export default function SearchInput({ onChange, defaultValue = '' }) {
export default function SearchInput({ onChange }) {
const formatMessage = useFormatMessage();
return (
<FormControl variant="outlined" fullWidth>
@@ -16,7 +16,6 @@ export default function SearchInput({ onChange, defaultValue = '' }) {
</InputLabel>
<OutlinedInput
defaultValue={defaultValue}
id="search-input"
type="text"
size="medium"
@@ -35,5 +34,4 @@ export default function SearchInput({ onChange, defaultValue = '' }) {
SearchInput.propTypes = {
onChange: PropTypes.func,
defaultValue: PropTypes.string,
};

View File

@@ -7,9 +7,9 @@ function Variable({ attributes, children, element, disabled }) {
const focused = useFocused();
const label = (
<>
{children}
<span style={{ fontWeight: 500 }}>{element.name}</span>:{' '}
<span style={{ fontWeight: 300 }}>{element.sampleValue}</span>
{children}
</>
);
return (

View File

@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import Button from '@mui/material/Button';
import ButtonGroup from '@mui/material/ButtonGroup';
import { ClickAwayListener } from '@mui/base/ClickAwayListener';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import Grow from '@mui/material/Grow';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';

View File

@@ -11,17 +11,14 @@ export default function SubscriptionCancelledAlert() {
const formatMessage = useFormatMessage();
const subscription = useSubscription();
const trial = useUserTrial();
if (subscription?.data?.status === 'active' || trial.hasTrial)
return <React.Fragment />;
const cancellationEffectiveDateObject = DateTime.fromISO(
subscription?.data?.cancellationEffectiveDate,
);
if (
subscription?.data?.status === 'active' ||
trial.hasTrial ||
!cancellationEffectiveDateObject.isValid
)
return <React.Fragment />;
return (
<Alert
severity="warning"

View File

@@ -84,7 +84,10 @@ function TestSubstep(props) {
}}
>
{hasError && (
<Alert severity="error" sx={{ mb: 2, width: '100%' }}>
<Alert
severity="error"
sx={{ mb: 2, fontWeight: 500, width: '100%' }}
>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
{JSON.stringify(errorDetails, null, 2)}
</pre>
@@ -101,11 +104,13 @@ function TestSubstep(props) {
severity="warning"
sx={{ mb: 1, width: '100%' }}
>
<AlertTitle>
<AlertTitle sx={{ fontWeight: 700 }}>
{formatMessage('flowEditor.noTestDataTitle')}
</AlertTitle>
<Box>{formatMessage('flowEditor.noTestDataMessage')}</Box>
<Box sx={{ fontWeight: 400 }}>
{formatMessage('flowEditor.noTestDataMessage')}
</Box>
</Alert>
)}

View File

@@ -31,7 +31,6 @@ function TextField(props) {
onBlur,
onChange,
'data-test': dataTest,
showError = false,
...textFieldProps
} = props;
return (
@@ -48,7 +47,6 @@ function TextField(props) {
onBlur: controllerOnBlur,
...field
},
fieldState: { error },
}) => (
<MuiTextField
{...textFieldProps}
@@ -74,7 +72,6 @@ function TextField(props) {
inputProps={{
'data-test': dataTest,
}}
{...(showError && { helperText: error?.message, error: !!error })}
/>
)}
/>
@@ -92,7 +89,6 @@ TextField.propTypes = {
disabled: PropTypes.bool,
onBlur: PropTypes.func,
onChange: PropTypes.func,
showError: PropTypes.bool,
};
export default TextField;

View File

@@ -44,7 +44,7 @@ function BillingCard(props) {
</Typography>
<Typography variant="h6" fontWeight="bold">
{title || '---'}
{title}
</Typography>
</CardContent>
@@ -119,12 +119,12 @@ export default function UsageDataInformation() {
text: 'Upgrade plan',
},
nextBillAmount: {
title: null,
title: '---',
action: null,
text: null,
},
nextBillDate: {
title: null,
title: '---',
action: null,
text: null,
},
@@ -137,9 +137,7 @@ export default function UsageDataInformation() {
text: formatMessage('usageDataInformation.cancelPlan'),
},
nextBillAmount: {
title: subscription?.nextBillAmount
? `${subscription?.nextBillAmount}`
: null,
title: `${subscription?.nextBillAmount}`,
action: subscription?.updateUrl,
text: formatMessage('usageDataInformation.updatePaymentMethod'),
},

View File

@@ -45,36 +45,3 @@ export function getPermissions(computedPermissions) {
[],
);
}
export const getComputedPermissionsDefaultValues = (
data,
conditionsInitialValues,
) => {
if (!data) return {};
const conditions = {};
data.conditions.forEach((condition) => {
conditions[condition.key] =
conditionsInitialValues?.[condition.key] || false;
});
const result = {};
data.subjects.forEach((subject) => {
const subjectKey = subject.key;
result[subjectKey] = {};
data.actions.forEach((action) => {
const actionKey = action.key;
if (action.subjects.includes(subjectKey)) {
result[subjectKey][actionKey] = {
value: false,
conditions: { ...conditions },
};
}
});
});
return result;
};

View File

@@ -5,8 +5,6 @@ import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import { useMemo } from 'react';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import Form from 'components/Form';
import useFormatMessage from 'hooks/useFormatMessage';
@@ -25,49 +23,13 @@ function generateFormRoleMappings(roleMappings) {
}));
}
const uniqueRemoteRoleName = (array, context, formatMessage) => {
const seen = new Set();
for (const [index, value] of array.entries()) {
if (seen.has(value.remoteRoleName)) {
const path = `${context.path}[${index}].remoteRoleName`;
return context.createError({
message: `${formatMessage('roleMappingsForm.remoteRoleName')} must be unique`,
path,
});
}
seen.add(value.remoteRoleName);
}
return true;
};
const getValidationSchema = (formatMessage) =>
yup.object({
roleMappings: yup
.array()
.of(
yup.object({
roleId: yup
.string()
.required(`${formatMessage('roleMappingsForm.role')} is required`),
remoteRoleName: yup
.string()
.required(
`${formatMessage('roleMappingsForm.remoteRoleName')} is required`,
),
}),
)
.test('unique-remoteRoleName', '', (value, ctx) => {
return uniqueRemoteRoleName(value, ctx, formatMessage);
}),
});
function RoleMappings({ provider, providerLoading }) {
const formatMessage = useFormatMessage();
const enqueueSnackbar = useEnqueueSnackbar();
const {
mutateAsync: updateRoleMappings,
isPending: isUpdateRoleMappingsPending,
mutateAsync: updateSamlAuthProvidersRoleMappings,
isPending: isUpdateSamlAuthProvidersRoleMappingsPending,
} = useAdminUpdateSamlAuthProviderRoleMappings(provider?.id);
const { data, isLoading: isAdminSamlAuthProviderRoleMappingsLoading } =
@@ -79,7 +41,7 @@ function RoleMappings({ provider, providerLoading }) {
const handleRoleMappingsUpdate = async (values) => {
try {
if (provider?.id) {
await updateRoleMappings(
await updateSamlAuthProvidersRoleMappings(
values.roleMappings.map(({ roleId, remoteRoleName }) => ({
roleId,
remoteRoleName,
@@ -132,15 +94,7 @@ function RoleMappings({ provider, providerLoading }) {
<Typography variant="h3">
{formatMessage('roleMappingsForm.title')}
</Typography>
<Form
defaultValues={defaultValues}
onSubmit={handleRoleMappingsUpdate}
resolver={yupResolver(getValidationSchema(formatMessage))}
mode="onSubmit"
reValidateMode="onChange"
noValidate
automaticValidation={false}
>
<Form defaultValues={defaultValues} onSubmit={handleRoleMappingsUpdate}>
<Stack direction="column" spacing={2}>
<RoleMappingsFieldArray />
<LoadingButton
@@ -148,7 +102,7 @@ function RoleMappings({ provider, providerLoading }) {
variant="contained"
color="primary"
sx={{ boxShadow: 2 }}
loading={isUpdateRoleMappingsPending}
loading={isUpdateSamlAuthProvidersRoleMappingsPending}
>
{formatMessage('roleMappingsForm.save')}
</LoadingButton>

View File

@@ -55,7 +55,6 @@ function RoleMappingsFieldArray() {
label={formatMessage('roleMappingsForm.remoteRoleName')}
fullWidth
required
showError
/>
<ControlledAutocomplete
name={`roleMappings.${index}.roleId`}
@@ -63,17 +62,14 @@ function RoleMappingsFieldArray() {
disablePortal
disableClearable
options={generateRoleOptions(roles)}
renderInput={(params, { error }) => (
renderInput={(params) => (
<MuiTextField
{...params}
label={formatMessage('roleMappingsForm.role')}
required
error={!!error}
helperText={error?.message}
/>
)}
loading={isRolesLoading}
showHelperText={false}
/>
</Stack>
<IconButton

View File

@@ -11,13 +11,9 @@ import Form from 'components/Form';
import PageTitle from 'components/PageTitle';
import TextField from 'components/TextField';
import * as URLS from 'config/urls';
import {
getComputedPermissionsDefaultValues,
getPermissions,
} from 'helpers/computePermissions.ee';
import { getPermissions } from 'helpers/computePermissions.ee';
import useFormatMessage from 'hooks/useFormatMessage';
import useAdminCreateRole from 'hooks/useAdminCreateRole';
import usePermissionCatalog from 'hooks/usePermissionCatalog.ee';
export default function CreateRole() {
const navigate = useNavigate();
@@ -25,21 +21,6 @@ export default function CreateRole() {
const enqueueSnackbar = useEnqueueSnackbar();
const { mutateAsync: createRole, isPending: isCreateRolePending } =
useAdminCreateRole();
const { data: permissionCatalogData } = usePermissionCatalog();
const defaultValues = React.useMemo(
() => ({
name: '',
description: '',
computedPermissions: getComputedPermissionsDefaultValues(
permissionCatalogData?.data,
{
isCreator: true,
},
),
}),
[permissionCatalogData],
);
const handleRoleCreation = async (roleData) => {
try {
@@ -83,7 +64,7 @@ export default function CreateRole() {
</Grid>
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
<Form onSubmit={handleRoleCreation} defaultValues={defaultValues}>
<Form onSubmit={handleRoleCreation}>
<Stack direction="column" gap={2}>
<TextField
required={true}
@@ -100,7 +81,10 @@ export default function CreateRole() {
data-test="description-input"
/>
<PermissionCatalogField name="computedPermissions" />
<PermissionCatalogField
name="computedPermissions"
defaultChecked={true}
/>
<LoadingButton
type="submit"

View File

@@ -124,6 +124,7 @@ export default function CreateUser() {
<Alert
severity="info"
color="primary"
sx={{ fontWeight: '500' }}
data-test="invitation-email-info-alert"
>
{formatMessage('createUser.invitationEmailInfo', {

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