Compare commits

...

40 Commits

Author SHA1 Message Date
Rıdvan Akca
6889c15240 feat(microsoft-teams): add microsoft teams integration 2023-11-06 18:12:22 +03:00
Rıdvan Akca
4ff824663b test: skip admin role is not deletable 2023-11-06 14:21:19 +01:00
QAComet
1581b5ac0a test: write tests for role management (#1396) 2023-11-06 10:35:20 +01:00
Ömer Faruk Aydın
5fb48ed54b Merge pull request #1402 from automatisch/tests/get-flow
test: Implement graphQL getFlow query tests
2023-10-31 16:12:26 +01:00
Faruk AYDIN
903e9e6093 test: Implement graphQL getFlow query tests 2023-10-31 15:57:44 +01:00
Faruk AYDIN
d30e491817 test: Delete formattedData from connection factory before persisting 2023-10-31 15:57:22 +01:00
Faruk AYDIN
aa727e3260 test: Adjust step factory to assign correct appKey 2023-10-31 15:56:49 +01:00
Ömer Faruk Aydın
1cad3a7149 Merge pull request #1398 from automatisch/tests/disable-typescript
test: Disable ts check for test files
2023-10-31 12:08:30 +01:00
Ali BARIN
3b7f6740bb chore: exclude test files from eslint 2023-10-31 11:35:53 +01:00
Ali BARIN
2febc5efad fix(mutations/delete-flow): cover incomplete trigger 2023-10-30 15:19:31 +01:00
Faruk AYDIN
903616bef6 test: Disable ts check for test files 2023-10-30 14:25:22 +01:00
Rıdvan Akca
c944193fb4 feat(trello): add create card action 2023-10-30 11:48:19 +01:00
Ömer Faruk Aydın
4f2155ea63 Merge pull request #1394 from automatisch/tests/coverage
test: Show coverage for graphQL queries folder
2023-10-30 10:23:42 +01:00
Faruk AYDIN
4bda1edda7 test: Show coverage for graphQL queries folder 2023-10-29 13:13:01 +01:00
QAComet
1a55cc8604 test: update snackbar with variant and data-test attributes 2023-10-27 19:48:40 +02:00
Ali BARIN
bf7ab475ee feat: unregister webhook upon deleting flow 2023-10-27 19:19:13 +02:00
Ömer Faruk Aydın
2f39efb935 Merge pull request #1381 from automatisch/add-filters-in-get-executions-query
feat(queries/get-executions): add filter support
2023-10-26 17:56:10 +02:00
Ali BARIN
9f8eb985e4 refactor: assert entry dates as string 2023-10-26 15:43:11 +00:00
Ali BARIN
3549fef71c feat(ExecutionRow): use createdAt instead of updatedAt 2023-10-26 15:43:10 +00:00
Ali BARIN
2cfa64c2a3 test(queries/get-executions): use createdAt in filter test cases 2023-10-26 15:43:08 +00:00
Ali BARIN
7245a0a599 refactor(queries/get-executions): use createdAt in filters 2023-10-26 15:29:28 +00:00
Ali BARIN
0633da3244 fix(mutations/duplicate-flow): correct webhook path 2023-10-26 17:21:28 +02:00
Faruk AYDIN
96341976f5 test: Add graphQL query test for getExecutions 2023-10-26 17:19:37 +02:00
Faruk AYDIN
9abfaec4d5 test: Adjust permission factory to pass all values 2023-10-26 17:17:12 +02:00
Faruk AYDIN
945c52dd6b test: Add createdAt and updatedAt defaultst to execution and flow 2023-10-26 17:16:42 +02:00
Faruk AYDIN
6567d24760 test: Adjust app key of step depending on type 2023-10-26 17:15:50 +02:00
Faruk AYDIN
ffaf9b6e0c chore: Add date types to IFlow and IExecution 2023-10-26 17:15:10 +02:00
QAComet
463e6908b1 test: write tests for user management (#1316)
* chore: add data-test attributes

* test: add github connection test, add applications modal

* test: write tests for user management
2023-10-26 15:12:37 +02:00
Ali BARIN
e185ceb385 fix(queries/get-executions): recover flow and steps relations 2023-10-26 13:52:20 +02:00
Ali BARIN
1b21bbe5b7 feat(queries/get-executions): accept timestamp instead of ISO datetime 2023-10-26 13:52:20 +02:00
Ali BARIN
14b7053ed8 feat: add updated_at index in executions 2023-10-26 13:52:20 +02:00
Ali BARIN
2760526def feat: add flow_id index in executions 2023-10-26 13:52:20 +02:00
Ali BARIN
d851db22d0 feat(queries/get-executions): add updatedAt filter support 2023-10-26 13:52:20 +02:00
Ali BARIN
2fa360e400 feat(queries/get-executions): add status filter support 2023-10-26 13:52:20 +02:00
Ali BARIN
e4eb146169 feat(queries/get-executions): add flowId filter support 2023-10-26 13:52:20 +02:00
Moaaz Elsayed
86611453b5 feat(zendesk): add zendesk integration (#1385)
* feat(zendesk): add zendesk integration

* Add Zendesk connection documentation

* docs(zendesk/connection): add missing steps

* feat(zendesk): add more auth scopes for planned triggers/actions

* fix(zendesk): fix instanceUrl

---------

Co-authored-by: Ali BARIN <ali.barin53@gmail.com>
2023-10-26 12:47:13 +02:00
dependabot[bot]
65f9d1b6b9 chore(deps): bump crypto-js from 4.1.1 to 4.2.0
Bumps [crypto-js](https://github.com/brix/crypto-js) from 4.1.1 to 4.2.0.
- [Commits](https://github.com/brix/crypto-js/compare/4.1.1...4.2.0)

---
updated-dependencies:
- dependency-name: crypto-js
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-26 10:00:04 +02:00
QAComet
2fceaf2cf4 test: fix flakiness in GH connection test case (#1383) 2023-10-25 11:04:04 +02:00
Ömer Faruk Aydın
d82b50fcdb Merge pull request #1382 from automatisch/fix/delete-current-user
fix: Guard lowercase email for delete user operation
2023-10-25 01:17:52 +02:00
Faruk AYDIN
ab6e49bf4f fix: Guard lowercase email for delete user operation 2023-10-25 01:00:43 +02:00
114 changed files with 3719 additions and 217 deletions

View File

@@ -7,4 +7,12 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
'prettier',
],
overrides: [
{
files: ['**/*.test.ts', '**/test/**/*.ts'],
rules: {
'@typescript-eslint/ban-ts-comment': ['off'],
},
},
],
};

View File

@@ -4,4 +4,6 @@ module.exports = {
testEnvironment: 'node',
setupFilesAfterEnv: ['./test/setup/global-hooks.ts'],
globalTeardown: './test/setup/global-teardown.ts',
collectCoverage: true,
collectCoverageFrom: ['src/graphql/queries/*.ts'],
};

View File

@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024" width="1024" height="1024" >
<defs>
<linearGradient id="plate-fill" x1="-.2" y1="-.2" x2=".8" y2=".8">
<stop offset="0" stop-color="#5a62c4"></stop>
<stop offset="1" stop-color="#3940ab"></stop>
</linearGradient>
<style>
.cls-1{fill:#5059c9}.cls-2{fill:#7b83eb}
</style>
<filter id="person-shadow" x="-50%" y="-50%" width="300%" height="300%">
<feGaussianBlur in="SourceAlpha" stdDeviation="25"></feGaussianBlur>
<feOffset dy="25"></feOffset>
<feComponentTransfer>
<feFuncA type="linear" slope=".25"></feFuncA>
</feComponentTransfer>
<feMerge>
<feMergeNode></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<filter id="back-plate-shadow" x="-50%" y="-50%" width="300%" height="300%">
<feGaussianBlur in="SourceAlpha" stdDeviation="24"></feGaussianBlur>
<feOffset dx="2" dy="24"></feOffset>
<feComponentTransfer>
<feFuncA type="linear" slope=".6"></feFuncA>
</feComponentTransfer>
<feMerge>
<feMergeNode></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<filter id="tee-shadow" x="-50%" y="-50%" width="250%" height="250%">
<feGaussianBlur in="SourceAlpha" stdDeviation="12"></feGaussianBlur>
<feOffset dx="10" dy="20"></feOffset>
<feComponentTransfer>
<feFuncA type="linear" slope=".2"></feFuncA>
</feComponentTransfer>
<feMerge>
<feMergeNode></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<clipPath id="back-plate-clip">
<path d="M684 432H512v-49.143A112 112 0 1 0 416 272a111.556 111.556 0 0 0 10.785 48H160a32.094 32.094 0 0 0-32 32v320a32.094 32.094 0 0 0 32 32h178.67c15.236 90.8 94.2 160 189.33 160 106.039 0 192-85.961 192-192V468a36 36 0 0 0-36-36z" fill="#fff"></path>
</clipPath>
</defs>
<g id="small_person" filter="url(#person-shadow)">
<path id="Body" class="cls-1" d="M692 432h168a36 36 0 0 1 36 36v164a120 120 0 0 1-120 120 120 120 0 0 1-120-120V468a36 36 0 0 1 36-36z"></path>
<circle id="Head" class="cls-1" cx="776" cy="304" r="80"></circle>
</g>
<g id="Large_Person" filter="url(#person-shadow)">
<path id="Body-2" data-name="Body" class="cls-2" d="M372 432h312a36 36 0 0 1 36 36v204a192 192 0 0 1-192 192 192 192 0 0 1-192-192V468a36 36 0 0 1 36-36z"></path>
<circle id="Head-2" data-name="Head" class="cls-2" cx="528" cy="272" r="112"></circle>
</g>
<rect id="Back_Plate" x="128" y="320" width="384" height="384" rx="32" ry="32" filter="url(#back-plate-shadow)" clip-path="url(#back-plate-clip)" fill="url(#plate-fill)"></rect>
<path id="Letter_T" d="M399.365 445.855h-60.293v164.2h-38.418v-164.2h-60.02V414h158.73z" filter="url(#tee-shadow)" fill="#fff"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,23 @@
import { IField, IGlobalVariable } from '@automatisch/types';
import { URLSearchParams } from 'url';
import authScope from '../common/auth-scope';
export default async function generateAuthUrl($: IGlobalVariable) {
const oauthRedirectUrlField = $.app.auth.fields.find(
(field: IField) => field.key == 'oAuthRedirectUrl'
);
const redirectUri = oauthRedirectUrlField.value as string;
const searchParams = new URLSearchParams({
client_id: $.auth.data.clientId as string,
response_type: 'code',
redirect_uri: redirectUri,
response_mode: 'query',
scope: authScope.join(' '),
});
const url = `https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?${searchParams.toString()}`;
await $.auth.set({
url,
});
}

View File

@@ -0,0 +1,48 @@
import generateAuthUrl from './generate-auth-url';
import verifyCredentials from './verify-credentials';
import refreshToken from './refresh-token';
import isStillVerified from './is-still-verified';
export default {
fields: [
{
key: 'oAuthRedirectUrl',
label: 'OAuth Redirect URL',
type: 'string' as const,
required: true,
readOnly: true,
value: '{WEB_APP_URL}/app/microsoft-teams/connections/add',
placeholder: null,
description:
'When asked to input a redirect URL in Microsoft identity platform, enter the URL above.',
clickToCopy: true,
},
{
key: 'clientId',
label: 'Client ID',
type: 'string' as const,
required: true,
readOnly: false,
value: null,
placeholder: null,
description: null,
clickToCopy: false,
},
{
key: 'clientSecret',
label: 'Client Secret',
type: 'string' as const,
required: true,
readOnly: false,
value: null,
placeholder: null,
description: null,
clickToCopy: false,
},
],
generateAuthUrl,
verifyCredentials,
isStillVerified,
refreshToken,
};

View File

@@ -0,0 +1,9 @@
import { IGlobalVariable } from '@automatisch/types';
import getCurrentUser from '../common/get-current-user';
const isStillVerified = async ($: IGlobalVariable) => {
const currentUser = await getCurrentUser($);
return !!currentUser.displayName;
};
export default isStillVerified;

View File

@@ -0,0 +1,27 @@
import { URLSearchParams } from 'node:url';
import { IGlobalVariable } from '@automatisch/types';
import authScope from '../common/auth-scope';
const refreshToken = async ($: IGlobalVariable) => {
const params = new URLSearchParams({
client_id: $.auth.data.clientId as string,
client_secret: $.auth.data.clientSecret as string,
grant_type: 'refresh_token',
refresh_token: $.auth.data.refreshToken as string,
});
const { data } = await $.http.post(
'https://login.microsoftonline.com/organizations/oauth2/v2.0/token',
params.toString()
);
await $.auth.set({
accessToken: data.access_token,
expiresIn: data.expires_in,
scope: authScope.join(' '),
tokenType: data.token_type,
refreshToken: data.refresh_token,
});
};
export default refreshToken;

View File

@@ -0,0 +1,53 @@
import { IField, IGlobalVariable } from '@automatisch/types';
import getCurrentUser from '../common/get-current-user';
import authScope from '../common/auth-scope';
import { URLSearchParams } from 'node:url';
const verifyCredentials = async ($: IGlobalVariable) => {
const oauthRedirectUrlField = $.app.auth.fields.find(
(field: IField) => field.key == 'oAuthRedirectUrl'
);
const redirectUri = oauthRedirectUrlField.value as string;
const params = new URLSearchParams({
client_id: $.auth.data.clientId as string,
scope: authScope.join(' '),
code: $.auth.data.code as string,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
client_secret: $.auth.data.clientSecret as string,
});
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
};
const { data } = await $.http.post(
`https://login.microsoftonline.com/organizations/oauth2/v2.0/token`,
params.toString(),
{ headers }
);
await $.auth.set({
accessToken: data.access_token,
tokenType: data.token_type,
});
const currentUser = await getCurrentUser($);
const screenName = [currentUser.displayName, $.auth.data.mail]
.filter(Boolean)
.join(' @ ');
await $.auth.set({
clientId: $.auth.data.clientId,
clientSecret: $.auth.data.clientSecret,
scope: data.scope,
expiresIn: data.expires_in,
extExpiresIn: data.ext_expires_in,
refreshToken: data.refresh_token,
screenName,
});
};
export default verifyCredentials;

View File

@@ -0,0 +1,13 @@
import { TBeforeRequest } from '@automatisch/types';
const addAuthHeader: TBeforeRequest = ($, requestConfig) => {
requestConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded';
if ($.auth.data?.accessToken) {
requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`;
}
return requestConfig;
};
export default addAuthHeader;

View File

@@ -0,0 +1,9 @@
const authScope: string[] = [
'offline_access',
'email',
'User.Read',
'openid',
'profile',
];
export default authScope;

View File

@@ -0,0 +1,8 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
const getCurrentUser = async ($: IGlobalVariable): Promise<IJSONObject> => {
const response = await $.http.get('/v1.0/me');
return response.data;
};
export default getCurrentUser;

View File

View File

@@ -0,0 +1,16 @@
import defineApp from '../../helpers/define-app';
import addAuthHeader from './common/add-auth-header';
import auth from './auth';
export default defineApp({
name: 'Microsoft Teams',
key: 'microsoft-teams',
baseUrl: 'https://teams.live.com',
apiBaseUrl: 'https://graph.microsoft.com',
iconUrl: '{BASE_URL}/apps/microsoft-teams/assets/favicon.svg',
authDocUrl: 'https://automatisch.io/docs/apps/microsoft-teams/connection',
primaryColor: '464EB8',
supportsConnections: true,
beforeRequest: [addAuthHeader],
auth,
});

View File

@@ -0,0 +1,189 @@
import { IJSONArray, IJSONObject } from '@automatisch/types';
import defineAction from '../../../../helpers/define-action';
export default defineAction({
name: 'Create card',
key: 'createCard',
description: 'Creates a new card within a specified board and list.',
arguments: [
{
label: 'Board',
key: 'boardId',
type: 'dropdown' as const,
required: true,
description: '',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listBoards',
},
],
},
},
{
label: 'List',
key: 'listId',
type: 'dropdown' as const,
required: true,
dependsOn: ['parameters.boardId'],
description: '',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listBoardLists',
},
{
name: 'parameters.boardId',
value: '{parameters.boardId}',
},
],
},
},
{
label: 'Name',
key: 'name',
type: 'string' as const,
required: true,
variables: true,
description: '',
},
{
label: 'Description',
key: 'description',
type: 'string' as const,
required: false,
variables: true,
description: '',
},
{
label: 'Label',
key: 'label',
type: 'dropdown' as const,
required: false,
dependsOn: ['parameters.boardId'],
description: 'Select a color tag to attach to the card.',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listBoardLabels',
},
{
name: 'parameters.boardId',
value: '{parameters.boardId}',
},
],
},
},
{
label: 'Card Position',
key: 'cardPosition',
type: 'dropdown' as const,
required: false,
description: '',
variables: true,
options: [
{
label: 'top',
value: 'top',
},
{
label: 'bottom',
value: 'bottom',
},
],
},
{
label: 'Members',
key: 'memberIds',
type: 'dynamic' as const,
required: false,
description: '',
fields: [
{
label: 'Member',
key: 'memberId',
type: 'dropdown' as const,
required: false,
dependsOn: ['parameters.boardId'],
description: '',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listMembers',
},
{
name: 'parameters.boardId',
value: '{parameters.boardId}',
},
],
},
},
],
},
{
label: 'Due Date',
key: 'dueDate',
type: 'string' as const,
required: false,
variables: true,
description: 'Format: mm-dd-yyyy HH:mm:ss or yyyy-MM-dd HH:mm:ss.',
},
{
label: 'URL Attachment',
key: 'urlSource',
type: 'string' as const,
required: false,
variables: true,
description: 'A URL to attach to the card.',
},
],
async run($) {
const {
listId,
name,
description,
cardPosition,
dueDate,
label,
urlSource,
} = $.step.parameters;
const memberIds = $.step.parameters.memberIds as IJSONArray;
const idMembers = memberIds.map(
(memberId: IJSONObject) => memberId.memberId
);
const fields = {
name,
desc: description,
idList: listId,
pos: cardPosition,
due: dueDate,
idMembers: idMembers.join(','),
idLabels: label,
urlSource,
};
const response = await $.http.post('/1/cards', fields);
$.setActionItem({ raw: response.data });
},
});

View File

@@ -0,0 +1,3 @@
import createCard from './create-card';
export default [createCard];

View File

@@ -5,6 +5,8 @@ const addAuthHeader: TBeforeRequest = ($, requestConfig) => {
requestConfig.headers.Authorization = `OAuth oauth_consumer_key="${$.auth.data.apiKey}", oauth_token="${$.auth.data.token}"`;
}
requestConfig.headers.Accept = 'application/json';
return requestConfig;
};

View File

@@ -0,0 +1,6 @@
import listBoardLabels from './list-board-labels';
import listBoardLists from './list-board-lists';
import listBoards from './list-boards';
import listMembers from './listMembers';
export default [listBoardLabels, listBoardLists, listBoards, listMembers];

View File

@@ -0,0 +1,39 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
export default {
name: 'List board labels',
key: 'listBoardLabels',
async run($: IGlobalVariable) {
const boardLabels: {
data: IJSONObject[];
} = {
data: [],
};
const boardId = $.step.parameters.boardId;
if (!boardId) {
return boardLabels;
}
const params = {
fields: 'color',
};
const { data } = await $.http.get(`/1/boards/${boardId}/labels`, {
params,
});
if (data?.length) {
for (const boardLabel of data) {
boardLabels.data.push({
value: boardLabel.id,
name: boardLabel.color,
});
}
}
return boardLabels;
},
};

View File

@@ -0,0 +1,33 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
export default {
name: 'List board lists',
key: 'listBoardLists',
async run($: IGlobalVariable) {
const boards: {
data: IJSONObject[];
} = {
data: [],
};
const boardId = $.step.parameters.boardId;
if (!boardId) {
return boards;
}
const { data } = await $.http.get(`/1/boards/${boardId}/lists`);
if (data?.length) {
for (const list of data) {
boards.data.push({
value: list.id,
name: list.name,
});
}
}
return boards;
},
};

View File

@@ -0,0 +1,27 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
export default {
name: 'List boards',
key: 'listBoards',
async run($: IGlobalVariable) {
const boards: {
data: IJSONObject[];
} = {
data: [],
};
const { data } = await $.http.get(`/1/members/me/boards`);
if (data?.length) {
for (const board of data) {
boards.data.push({
value: board.id,
name: board.name,
});
}
}
return boards;
},
};

View File

@@ -0,0 +1,33 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
export default {
name: 'List members',
key: 'listMembers',
async run($: IGlobalVariable) {
const members: {
data: IJSONObject[];
} = {
data: [],
};
const boardId = $.step.parameters.boardId;
if (!boardId) {
return members;
}
const { data } = await $.http.get(`/1/boards/${boardId}/members`);
if (data?.length) {
for (const member of data) {
members.data.push({
value: member.id,
name: member.fullName,
});
}
}
return members;
},
};

View File

@@ -1,6 +1,8 @@
import defineApp from '../../helpers/define-app';
import addAuthHeader from './common/add-auth-header';
import auth from './auth';
import actions from './actions';
import dynamicData from './dynamic-data';
export default defineApp({
name: 'Trello',
@@ -13,4 +15,6 @@ export default defineApp({
primaryColor: '0079bf',
beforeRequest: [addAuthHeader],
auth,
actions,
dynamicData,
});

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="363" height="259" fill="#03363d"><path d="M173.82 40.5v112.86H80.34L173.82 40.5zm0-40.5a46.74 46.74 0 1 1-93.48 0h93.48zm15.4 153.37a46.74 46.74 0 0 1 93.48 0h-93.48zm0-40.5V0h93.5l-93.5 112.86zm52.28 137.06a18.22 18.22 0 0 0 12.95-5l6.42 6.93c-4.24 4.36-10.12 7.6-19.26 7.6-15.67 0-25.8-10.4-25.8-24.46a24 24 0 0 1 24.37-24.47c15.56 0 24.38 11.84 23.6 28.26H227c1.3 6.82 6.1 11.17 14.47 11.17m11.2-19c-1-6.37-4.8-11.06-12.4-11.06-7.07 0-12 4-13.27 11.06h25.68zM0 249.4l28.3-28.76H.67v-9.02h40.76v9.2l-28.3 28.75h28.7v9.03H0v-9.2zm73.6.52a18.22 18.22 0 0 0 12.95-5l6.42 6.93c-4.24 4.36-10.12 7.6-19.26 7.6-15.67 0-25.8-10.4-25.8-24.46a24 24 0 0 1 24.37-24.47c15.56 0 24.38 11.84 23.6 28.26H59.12c1.3 6.82 6.1 11.17 14.47 11.17m11.2-19c-1-6.37-4.8-11.06-12.4-11.06-7.07 0-12 4-13.27 11.06H84.8zm72.23 4.03c0-15 11.23-24.44 23.6-24.44a20.34 20.34 0 0 1 15.67 7.05v-27.72h10v68.6h-10V252a20.1 20.1 0 0 1-15.76 7.42c-12 0-23.5-9.5-23.5-24.43m39.82-.1a14.92 14.92 0 1 0-14.91 15.32c8.6 0 14.9-6.86 14.9-15.32m73.48 13.6l9.06-4.7a13.44 13.44 0 0 0 12.08 6.86c5.66 0 8.6-2.9 8.6-6.2 0-3.76-5.47-4.6-11.42-5.83-8-1.7-16.33-4.33-16.33-14 0-7.43 7.07-14.3 18.2-14.2 8.77 0 15.3 3.48 19 9.1l-8.4 4.6a12.19 12.19 0 0 0-10.57-5.36c-5.38 0-8.12 2.63-8.12 5.64 0 3.38 4.34 4.32 11.14 5.83 7.74 1.7 16.5 4.23 16.5 14 0 6.48-5.66 15.22-19.06 15.13-9.8 0-16.7-3.95-20.67-10.9m66.9-10.87l-7.93 8.65v12.2h-10v-68.6h10v44.93l21.23-23.3h12.18l-18.4 20.1 18.88 26.88h-11.32l-14.63-20.86zM126.8 210.53c-11.9 0-21.85 7.7-21.85 20.5v27.45h10.2V232.3c0-7.7 4.43-12.32 12-12.32s11.33 4.6 11.33 12.32v26.18h10.14v-27.45c0-12.78-10-20.5-21.85-20.5"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,23 @@
import { IField, IGlobalVariable } from '@automatisch/types';
import { URLSearchParams } from 'url';
import authScope from '../common/auth-scope';
export default async function generateAuthUrl($: IGlobalVariable) {
const oauthRedirectUrlField = $.app.auth.fields.find(
(field: IField) => field.key == 'oAuthRedirectUrl'
);
const redirectUri = oauthRedirectUrlField.value as string;
const searchParams = new URLSearchParams({
response_type: 'code',
redirect_uri: redirectUri,
client_id: $.auth.data.clientId as string,
scope: authScope.join(' '),
});
await $.auth.set({
url: `${
$.auth.data.instanceUrl
}/oauth/authorizations/new?${searchParams.toString()}`,
});
}

View File

@@ -0,0 +1,55 @@
import generateAuthUrl from './generate-auth-url';
import verifyCredentials from './verify-credentials';
import isStillVerified from './is-still-verified';
export default {
fields: [
{
key: 'oAuthRedirectUrl',
label: 'OAuth Redirect URL',
type: 'string' as const,
required: true,
readOnly: true,
value: '{WEB_APP_URL}/app/zendesk/connections/add',
placeholder: null,
description: '',
clickToCopy: true,
},
{
key: 'instanceUrl',
label: 'Zendesk Subdomain Url',
type: 'string' as const,
required: true,
readOnly: false,
value: null,
placeholder: 'https://{{subdomain}}.zendesk.com',
clickToCopy: false,
},
{
key: 'clientId',
label: 'Client ID',
type: 'string' as const,
required: true,
readOnly: false,
value: null,
placeholder: null,
description: null,
clickToCopy: false,
},
{
key: 'clientSecret',
label: 'Client Secret',
type: 'string' as const,
required: true,
readOnly: false,
value: null,
placeholder: null,
description: null,
clickToCopy: false,
},
],
generateAuthUrl,
verifyCredentials,
isStillVerified,
};

View File

@@ -0,0 +1,9 @@
import { IGlobalVariable } from '@automatisch/types';
import getCurrentUser from '../common/get-current-user';
const isStillVerified = async ($: IGlobalVariable) => {
await getCurrentUser($);
return true;
};
export default isStillVerified;

View File

@@ -0,0 +1,56 @@
import { IGlobalVariable, IJSONValue, IField } from '@automatisch/types';
import getCurrentUser from '../common/get-current-user';
import scopes from '../common/auth-scope';
const verifyCredentials = async ($: IGlobalVariable) => {
await getAccessToken($);
const user = await getCurrentUser($);
const subdomain = extractSubdomain($.auth.data.instanceUrl);
const name = user.name as string;
const screenName = [name, subdomain].filter(Boolean).join(' @ ');
await $.auth.set({
screenName,
apiToken: $.auth.data.apiToken,
instanceUrl: $.auth.data.instanceUrl,
email: $.auth.data.email,
});
};
const getAccessToken = async ($: IGlobalVariable) => {
const oauthRedirectUrlField = $.app.auth.fields.find(
(field: IField) => field.key == 'oAuthRedirectUrl'
);
const redirectUri = oauthRedirectUrlField.value as string;
const response = await $.http.post(`/oauth/tokens`, {
redirect_uri: redirectUri,
code: $.auth.data.code,
grant_type: 'authorization_code',
scope: scopes.join(' '),
client_id: $.auth.data.clientId as string,
client_secret: $.auth.data.clientSecret as string,
});
const data = response.data;
$.auth.data.accessToken = data.access_token;
await $.auth.set({
clientId: $.auth.data.clientId,
clientSecret: $.auth.data.clientSecret,
accessToken: data.access_token,
tokenType: data.token_type,
});
};
function extractSubdomain(url: IJSONValue) {
const match = (url as string).match(/https:\/\/(.*?)\.zendesk\.com/);
if (match && match[1]) {
return match[1];
}
return null;
}
export default verifyCredentials;

View File

@@ -0,0 +1,17 @@
import { TBeforeRequest } from '@automatisch/types';
const addAuthHeader: TBeforeRequest = ($, requestConfig) => {
const { instanceUrl, tokenType, accessToken } = $.auth.data;
if (instanceUrl) {
requestConfig.baseURL = instanceUrl as string;
}
if (tokenType && accessToken) {
requestConfig.headers.Authorization = `${tokenType} ${$.auth.data.accessToken}`;
}
return requestConfig;
};
export default addAuthHeader;

View File

@@ -0,0 +1,3 @@
const authScope: string[] = ['read', 'write'];
export default authScope;

View File

@@ -0,0 +1,10 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
const getCurrentUser = async ($: IGlobalVariable): Promise<IJSONObject> => {
const response = await $.http.get('/api/v2/users/me');
const currentUser = response.data.user;
return currentUser;
};
export default getCurrentUser;

View File

View File

@@ -0,0 +1,16 @@
import defineApp from '../../helpers/define-app';
import addAuthHeader from './common/add-auth-headers';
import auth from './auth';
export default defineApp({
name: 'Zendesk',
key: 'zendesk',
baseUrl: 'https://zendesk.com/',
apiBaseUrl: '',
iconUrl: '{BASE_URL}/apps/zendesk/assets/favicon.svg',
authDocUrl: 'https://automatisch.io/docs/apps/zendesk/connection',
primaryColor: '17494d',
supportsConnections: true,
beforeRequest: [addAuthHeader],
auth,
});

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.table('executions', (table) => {
table.index('flow_id');
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.table('executions', (table) => {
table.dropIndex('flow_id');
});
}

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.table('executions', (table) => {
table.index('updated_at');
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.table('executions', (table) => {
table.dropIndex('updated_at');
});
}

View File

@@ -1,6 +1,8 @@
import Context from '../../types/express/context';
import Execution from '../../models/execution';
import ExecutionStep from '../../models/execution-step';
import globalVariable from '../../helpers/global-variable';
import logger from '../../helpers/logger';
type Params = {
input: {
@@ -22,6 +24,25 @@ const deleteFlow = async (
})
.throwIfNotFound();
const triggerStep = await flow.getTriggerStep();
const trigger = await triggerStep?.getTriggerCommand();
if (trigger?.type === 'webhook' && trigger.unregisterHook) {
const $ = await globalVariable({
flow,
connection: await triggerStep.$relatedQuery('connection'),
app: await triggerStep.getApp(),
step: triggerStep,
});
try {
await trigger.unregisterHook($);
} catch (error) {
// suppress error as the remote resource might have been already deleted
logger.debug(`Failed to unregister webhook for flow ${flow.id}: ${error.message}`);
}
}
const executionIds = (
await flow.$relatedQuery('executions').select('executions.id')
).map((execution: Execution) => execution.id);

View File

@@ -81,6 +81,10 @@ const duplicateFlow = async (
parameters: updateStepVariables(step.parameters, newStepIds),
});
if (duplicatedStep.isTrigger) {
await duplicatedStep.updateWebhookUrl();
}
newStepIds[step.id] = duplicatedStep.id;
}

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import request from 'supertest';
import app from '../../app';
import * as license from '../../helpers/license.ee';
@@ -22,7 +23,7 @@ describe('graphQL getAutomatischInfo query', () => {
beforeEach(async () => {
jest.spyOn(license, 'getLicense').mockResolvedValue(false);
jest.replaceProperty(appConfig, 'isCloud', false)
jest.replaceProperty(appConfig, 'isCloud', false);
});
it('should return empty license data', async () => {
@@ -36,9 +37,9 @@ describe('graphQL getAutomatischInfo query', () => {
getAutomatischInfo: {
isCloud: false,
license: {
id: null as string,
name: null as string,
expireAt: null as string,
id: null,
name: null,
expireAt: null,
verified: false,
},
},
@@ -63,7 +64,7 @@ describe('graphQL getAutomatischInfo query', () => {
describe('and with cloud flag enabled', () => {
beforeEach(async () => {
jest.replaceProperty(appConfig, 'isCloud', true)
jest.replaceProperty(appConfig, 'isCloud', true);
});
it('should return all license data', async () => {
@@ -92,7 +93,7 @@ describe('graphQL getAutomatischInfo query', () => {
describe('and with cloud flag disabled', () => {
beforeEach(async () => {
jest.replaceProperty(appConfig, 'isCloud', false)
jest.replaceProperty(appConfig, 'isCloud', false);
});
it('should return all license data', async () => {

View File

@@ -1,9 +1,9 @@
import request, { Test } from 'supertest';
// @ts-nocheck
import request from 'supertest';
import app from '../../app';
import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id';
import { createRole } from '../../../test/factories/role';
import { createUser } from '../../../test/factories/user';
import { IRole, IUser } from '@automatisch/types';
describe('graphQL getCurrentUser query', () => {
describe('with unauthenticated user', () => {
@@ -31,7 +31,7 @@ describe('graphQL getCurrentUser query', () => {
});
describe('with authenticated user', () => {
let role: IRole, currentUser: IUser, token: string, requestObject: Test;
let role, currentUser, token, requestObject;
beforeEach(async () => {
role = await createRole({
@@ -70,12 +70,12 @@ describe('graphQL getCurrentUser query', () => {
const expectedResponsePayload = {
data: {
getCurrentUser: {
createdAt: (currentUser.createdAt as Date).getTime().toString(),
createdAt: currentUser.createdAt.getTime().toString(),
email: currentUser.email,
fullName: currentUser.fullName,
id: currentUser.id,
role: { id: role.id, name: role.name },
updatedAt: (currentUser.updatedAt as Date).getTime().toString(),
updatedAt: currentUser.updatedAt.getTime().toString(),
},
},
};

View File

@@ -0,0 +1,489 @@
// @ts-nocheck
import request from 'supertest';
import app from '../../app';
import appConfig from '../../config/app';
import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id';
import { createRole } from '../../../test/factories/role';
import { createPermission } from '../../../test/factories/permission';
import { createUser } from '../../../test/factories/user';
import { createFlow } from '../../../test/factories/flow';
import { createStep } from '../../../test/factories/step';
import { createExecution } from '../../../test/factories/execution';
import { createExecutionStep } from '../../../test/factories/execution-step';
describe('graphQL getExecutions query', () => {
const query = `
query {
getExecutions(limit: 10, offset: 0) {
pageInfo {
currentPage
totalPages
}
edges {
node {
id
testRun
createdAt
updatedAt
status
flow {
id
name
active
steps {
iconUrl
}
}
}
}
}
}
`;
const invalidToken = 'invalid-token';
describe('with unauthenticated user', () => {
it('should throw not authorized error', async () => {
const response = await request(app)
.post('/graphql')
.set('Authorization', invalidToken)
.send({ query })
.expect(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toEqual('Not Authorised!');
});
});
describe('with authenticated user', () => {
describe('and without permissions', () => {
it('should throw not authorized error', async () => {
const userWithoutPermissions = await createUser();
const token = createAuthTokenByUserId(userWithoutPermissions.id);
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query })
.expect(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toEqual('Not authorized!');
});
});
describe('and with correct permission', () => {
let role,
currentUser,
anotherUser,
token,
flowOne,
stepOneForFlowOne,
stepTwoForFlowOne,
executionOne,
flowTwo,
stepOneForFlowTwo,
stepTwoForFlowTwo,
executionTwo,
flowThree,
stepOneForFlowThree,
stepTwoForFlowThree,
executionThree,
expectedResponseForExecutionOne,
expectedResponseForExecutionTwo,
expectedResponseForExecutionThree;
beforeEach(async () => {
role = await createRole({
key: 'sample',
name: 'sample',
});
currentUser = await createUser({
roleId: role.id,
fullName: 'Current User',
});
anotherUser = await createUser();
token = createAuthTokenByUserId(currentUser.id);
flowOne = await createFlow({
userId: currentUser.id,
});
stepOneForFlowOne = await createStep({
flowId: flowOne.id,
});
stepTwoForFlowOne = await createStep({
flowId: flowOne.id,
});
executionOne = await createExecution({
flowId: flowOne.id,
});
await createExecutionStep({
executionId: executionOne.id,
stepId: stepOneForFlowOne.id,
status: 'success',
});
await createExecutionStep({
executionId: executionOne.id,
stepId: stepTwoForFlowOne.id,
status: 'success',
});
flowTwo = await createFlow({
userId: currentUser.id,
});
stepOneForFlowTwo = await createStep({
flowId: flowTwo.id,
});
stepTwoForFlowTwo = await createStep({
flowId: flowTwo.id,
});
executionTwo = await createExecution({
flowId: flowTwo.id,
});
await createExecutionStep({
executionId: executionTwo.id,
stepId: stepOneForFlowTwo.id,
status: 'success',
});
await createExecutionStep({
executionId: executionTwo.id,
stepId: stepTwoForFlowTwo.id,
status: 'failure',
});
flowThree = await createFlow({
userId: anotherUser.id,
});
stepOneForFlowThree = await createStep({
flowId: flowThree.id,
});
stepTwoForFlowThree = await createStep({
flowId: flowThree.id,
});
executionThree = await createExecution({
flowId: flowThree.id,
});
await createExecutionStep({
executionId: executionThree.id,
stepId: stepOneForFlowThree.id,
status: 'success',
});
await createExecutionStep({
executionId: executionThree.id,
stepId: stepTwoForFlowThree.id,
status: 'failure',
});
expectedResponseForExecutionOne = {
node: {
createdAt: executionOne.createdAt.getTime().toString(),
flow: {
active: flowOne.active,
id: flowOne.id,
name: flowOne.name,
steps: [
{
iconUrl: `${appConfig.baseUrl}/apps/${stepOneForFlowOne.appKey}/assets/favicon.svg`,
},
{
iconUrl: `${appConfig.baseUrl}/apps/${stepTwoForFlowOne.appKey}/assets/favicon.svg`,
},
],
},
id: executionOne.id,
status: 'success',
testRun: executionOne.testRun,
updatedAt: executionOne.updatedAt.getTime().toString(),
},
};
expectedResponseForExecutionTwo = {
node: {
createdAt: executionTwo.createdAt.getTime().toString(),
flow: {
active: flowTwo.active,
id: flowTwo.id,
name: flowTwo.name,
steps: [
{
iconUrl: `${appConfig.baseUrl}/apps/${stepTwoForFlowTwo.appKey}/assets/favicon.svg`,
},
{
iconUrl: `${appConfig.baseUrl}/apps/${stepTwoForFlowTwo.appKey}/assets/favicon.svg`,
},
],
},
id: executionTwo.id,
status: 'failure',
testRun: executionTwo.testRun,
updatedAt: executionTwo.updatedAt.getTime().toString(),
},
};
expectedResponseForExecutionThree = {
node: {
createdAt: executionThree.createdAt.getTime().toString(),
flow: {
active: flowThree.active,
id: flowThree.id,
name: flowThree.name,
steps: [
{
iconUrl: `${appConfig.baseUrl}/apps/${stepOneForFlowThree.appKey}/assets/favicon.svg`,
},
{
iconUrl: `${appConfig.baseUrl}/apps/${stepTwoForFlowThree.appKey}/assets/favicon.svg`,
},
],
},
id: executionThree.id,
status: 'failure',
testRun: executionThree.testRun,
updatedAt: executionThree.updatedAt.getTime().toString(),
},
};
});
describe('and with isCreator condition', () => {
beforeEach(async () => {
await createPermission({
action: 'read',
subject: 'Execution',
roleId: role.id,
conditions: ['isCreator'],
});
});
it('should return executions data of the current user', async () => {
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query })
.expect(200);
const expectedResponsePayload = {
data: {
getExecutions: {
edges: [
expectedResponseForExecutionTwo,
expectedResponseForExecutionOne,
],
pageInfo: { currentPage: 1, totalPages: 1 },
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
describe('and without isCreator condition', () => {
beforeEach(async () => {
await createPermission({
action: 'read',
subject: 'Execution',
roleId: role.id,
conditions: [],
});
});
it('should return executions data of all users', async () => {
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query })
.expect(200);
const expectedResponsePayload = {
data: {
getExecutions: {
edges: [
expectedResponseForExecutionThree,
expectedResponseForExecutionTwo,
expectedResponseForExecutionOne,
],
pageInfo: { currentPage: 1, totalPages: 1 },
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
describe('and with filters', () => {
beforeEach(async () => {
await createPermission({
action: 'read',
subject: 'Execution',
roleId: role.id,
conditions: [],
});
});
it('should return executions data for the specified flow', async () => {
const query = `
query {
getExecutions(limit: 10, offset: 0, filters: { flowId: "${flowOne.id}" }) {
pageInfo {
currentPage
totalPages
}
edges {
node {
id
testRun
createdAt
updatedAt
status
flow {
id
name
active
steps {
iconUrl
}
}
}
}
}
}
`;
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query })
.expect(200);
const expectedResponsePayload = {
data: {
getExecutions: {
edges: [expectedResponseForExecutionOne],
pageInfo: { currentPage: 1, totalPages: 1 },
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
it('should return only executions data with success status', async () => {
const query = `
query {
getExecutions(limit: 10, offset: 0, filters: { status: "success" }) {
pageInfo {
currentPage
totalPages
}
edges {
node {
id
testRun
createdAt
updatedAt
status
flow {
id
name
active
steps {
iconUrl
}
}
}
}
}
}
`;
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query })
.expect(200);
const expectedResponsePayload = {
data: {
getExecutions: {
edges: [expectedResponseForExecutionOne],
pageInfo: { currentPage: 1, totalPages: 1 },
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
it('should return only executions data within date range', async () => {
const createdAtFrom = executionOne.createdAt.getTime().toString();
const createdAtTo = executionOne.createdAt.getTime().toString();
const query = `
query {
getExecutions(limit: 10, offset: 0, filters: { createdAt: { from: "${createdAtFrom}", to: "${createdAtTo}" }}) {
pageInfo {
currentPage
totalPages
}
edges {
node {
id
testRun
createdAt
updatedAt
status
flow {
id
name
active
steps {
iconUrl
}
}
}
}
}
}
`;
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query })
.expect(200);
const expectedResponsePayload = {
data: {
getExecutions: {
edges: [expectedResponseForExecutionOne],
pageInfo: { currentPage: 1, totalPages: 1 },
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
});
});
});

View File

@@ -1,11 +1,22 @@
import { raw } from 'objection';
import { DateTime } from 'luxon';
import Context from '../../types/express/context';
import Execution from '../../models/execution';
import paginate from '../../helpers/pagination';
type Filters = {
flowId?: string;
status?: string;
createdAt?: {
from?: string;
to?: string;
};
}
type Params = {
limit: number;
offset: number;
filters?: Filters;
};
const getExecutions = async (
@@ -15,6 +26,8 @@ const getExecutions = async (
) => {
const conditions = context.currentUser.can('read', 'Execution');
const filters = params.filters;
const userExecutions = context.currentUser.$relatedQuery('executions');
const allExecutions = Execution.query();
const executionBaseQuery = conditions.isCreator ? userExecutions : allExecutions;
@@ -32,16 +45,49 @@ const getExecutions = async (
.clone()
.joinRelated('executionSteps as execution_steps')
.select('executions.*', raw(selectStatusStatement))
.groupBy('executions.id')
.orderBy('created_at', 'desc');
const computedExecutions = Execution
.query()
.with('executions', executions)
.withSoftDeleted()
.withGraphFetched({
flow: {
steps: true,
},
})
.groupBy('executions.id')
.orderBy('updated_at', 'desc');
});
return paginate(executions, params.limit, params.offset);
if (filters?.flowId) {
computedExecutions.where('executions.flow_id', filters.flowId);
}
if (filters?.status) {
computedExecutions.where('executions.status', filters.status);
}
if (filters?.createdAt) {
const createdAtFilter = filters.createdAt;
if (createdAtFilter.from) {
const isoFromDateTime = DateTime
.fromMillis(
parseInt(createdAtFilter.from, 10)
)
.toISO();
computedExecutions.where('executions.created_at', '>=', isoFromDateTime);
}
if (createdAtFilter.to) {
const isoToDateTime = DateTime
.fromMillis(
parseInt(createdAtFilter.to, 10)
)
.toISO();
computedExecutions.where('executions.created_at', '<=', isoToDateTime);
}
}
return paginate(computedExecutions, params.limit, params.offset);
};
export default getExecutions;

View File

@@ -0,0 +1,262 @@
// @ts-nocheck
import request from 'supertest';
import app from '../../app';
import appConfig from '../../config/app';
import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id';
import { createRole } from '../../../test/factories/role';
import { createPermission } from '../../../test/factories/permission';
import { createUser } from '../../../test/factories/user';
import { createFlow } from '../../../test/factories/flow';
import { createStep } from '../../../test/factories/step';
import { createConnection } from '../../../test/factories/connection';
describe('graphQL getFlow query', () => {
const query = (flowId) => {
return `
query {
getFlow(id: "${flowId}") {
id
name
active
status
steps {
id
type
key
appKey
iconUrl
webhookUrl
status
position
connection {
id
verified
createdAt
}
parameters
}
}
}
`;
};
describe('with unauthenticated user', () => {
it('should throw not authorized error', async () => {
const invalidToken = 'invalid-token';
const flow = await createFlow();
const response = await request(app)
.post('/graphql')
.set('Authorization', invalidToken)
.send({ query: query(flow.id) })
.expect(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toEqual('Not Authorised!');
});
});
describe('with authenticated user', () => {
describe('and without permissions', () => {
it('should throw not authorized error', async () => {
const userWithoutPermissions = await createUser();
const token = createAuthTokenByUserId(userWithoutPermissions.id);
const flow = await createFlow();
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query: query(flow.id) })
.expect(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toEqual('Not authorized!');
});
});
describe('and with correct permission', () => {
let currentUser, currentUserRole, currentUserFlow;
beforeEach(async () => {
currentUserRole = await createRole();
currentUser = await createUser({ roleId: currentUserRole.id });
currentUserFlow = await createFlow({ userId: currentUser.id });
});
describe('and with isCreator condition', () => {
it('should return executions data of the current user', async () => {
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const triggerStep = await createStep({
flowId: currentUserFlow.id,
type: 'trigger',
key: 'catchRawWebhook',
webhookPath: `/webhooks/flows/${currentUserFlow.id}`,
});
const actionConnection = await createConnection({
userId: currentUser.id,
formattedData: {
screenName: 'Test',
authenticationKey: 'test key',
},
});
const actionStep = await createStep({
flowId: currentUserFlow.id,
type: 'action',
connectionId: actionConnection.id,
key: 'translateText',
});
const token = createAuthTokenByUserId(currentUser.id);
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query: query(currentUserFlow.id) })
.expect(200);
const expectedResponsePayload = {
data: {
getFlow: {
active: currentUserFlow.active,
id: currentUserFlow.id,
name: currentUserFlow.name,
status: 'draft',
steps: [
{
appKey: triggerStep.appKey,
connection: null,
iconUrl: `${appConfig.baseUrl}/apps/${triggerStep.appKey}/assets/favicon.svg`,
id: triggerStep.id,
key: 'catchRawWebhook',
parameters: {},
position: 1,
status: triggerStep.status,
type: 'trigger',
webhookUrl: `${appConfig.baseUrl}/webhooks/flows/${currentUserFlow.id}`,
},
{
appKey: actionStep.appKey,
connection: {
createdAt: actionConnection.createdAt
.getTime()
.toString(),
id: actionConnection.id,
verified: actionConnection.verified,
},
iconUrl: `${appConfig.baseUrl}/apps/${actionStep.appKey}/assets/favicon.svg`,
id: actionStep.id,
key: 'translateText',
parameters: {},
position: 1,
status: actionStep.status,
type: 'action',
webhookUrl: 'http://localhost:3000/null',
},
],
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
describe('and without isCreator condition', () => {
it('should return executions data of all users', async () => {
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
const anotherUser = await createUser();
const anotherUserFlow = await createFlow({ userId: anotherUser.id });
const triggerStep = await createStep({
flowId: anotherUserFlow.id,
type: 'trigger',
key: 'catchRawWebhook',
webhookPath: `/webhooks/flows/${anotherUserFlow.id}`,
});
const actionConnection = await createConnection({
userId: anotherUser.id,
formattedData: {
screenName: 'Test',
authenticationKey: 'test key',
},
});
const actionStep = await createStep({
flowId: anotherUserFlow.id,
type: 'action',
connectionId: actionConnection.id,
key: 'translateText',
});
const token = createAuthTokenByUserId(currentUser.id);
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query: query(anotherUserFlow.id) })
.expect(200);
const expectedResponsePayload = {
data: {
getFlow: {
active: anotherUserFlow.active,
id: anotherUserFlow.id,
name: anotherUserFlow.name,
status: 'draft',
steps: [
{
appKey: triggerStep.appKey,
connection: null,
iconUrl: `${appConfig.baseUrl}/apps/${triggerStep.appKey}/assets/favicon.svg`,
id: triggerStep.id,
key: 'catchRawWebhook',
parameters: {},
position: 1,
status: triggerStep.status,
type: 'trigger',
webhookUrl: `${appConfig.baseUrl}/webhooks/flows/${anotherUserFlow.id}`,
},
{
appKey: actionStep.appKey,
connection: {
createdAt: actionConnection.createdAt
.getTime()
.toString(),
id: actionConnection.id,
verified: actionConnection.verified,
},
iconUrl: `${appConfig.baseUrl}/apps/${actionStep.appKey}/assets/favicon.svg`,
id: actionStep.id,
key: 'translateText',
parameters: {},
position: 1,
status: actionStep.status,
type: 'action',
webhookUrl: 'http://localhost:3000/null',
},
],
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
});
});
});

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import request from 'supertest';
import app from '../../app';
import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id';
@@ -5,21 +6,20 @@ import Crypto from 'crypto';
import { createRole } from '../../../test/factories/role';
import { createPermission } from '../../../test/factories/permission';
import { createUser } from '../../../test/factories/user';
import { IRole, IUser, IPermission } from '@automatisch/types';
import * as license from '../../helpers/license.ee';
describe('graphQL getRole query', () => {
let validRole: IRole,
invalidRoleId: string,
queryWithValidRole: string,
queryWithInvalidRole: string,
userWithPermissions: IUser,
userWithoutPermissions: IUser,
tokenWithPermissions: string,
tokenWithoutPermissions: string,
invalidToken: string,
permissionOne: IPermission,
permissionTwo: IPermission;
let validRole,
invalidRoleId,
queryWithValidRole,
queryWithInvalidRole,
userWithPermissions,
userWithoutPermissions,
tokenWithPermissions,
tokenWithoutPermissions,
invalidToken,
permissionOne,
permissionTwo;
beforeEach(async () => {
validRole = await createRole();

View File

@@ -1,22 +1,22 @@
// @ts-nocheck
import request from 'supertest';
import app from '../../app';
import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id';
import { createRole } from '../../../test/factories/role';
import { createPermission } from '../../../test/factories/permission';
import { createUser } from '../../../test/factories/user';
import { IRole, IUser } from '@automatisch/types';
import * as license from '../../helpers/license.ee';
describe('graphQL getRoles query', () => {
let currentUserRole: IRole,
roleOne: IRole,
roleSecond: IRole,
query: string,
userWithPermissions: IUser,
userWithoutPermissions: IUser,
tokenWithPermissions: string,
tokenWithoutPermissions: string,
invalidToken: string;
let currentUserRole,
roleOne,
roleSecond,
query,
userWithPermissions,
userWithoutPermissions,
tokenWithPermissions,
tokenWithoutPermissions,
invalidToken;
beforeEach(async () => {
currentUserRole = await createRole({ name: 'Current user role' });

View File

@@ -1,6 +1,6 @@
// @ts-nocheck
import request from 'supertest';
import app from '../../app';
import { IUser } from '@automatisch/types';
import User from '../../models/user';
import { createUser } from '../../../test/factories/user';
import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id';
@@ -32,7 +32,7 @@ describe('graphQL getTrialStatus query', () => {
});
describe('with authenticated user', () => {
let user: IUser, userToken: string;
let user, userToken;
beforeEach(async () => {
const trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate();
@@ -54,7 +54,7 @@ describe('graphQL getTrialStatus query', () => {
.expect(200);
const expectedResponsePayload = {
data: { getTrialStatus: null as string },
data: { getTrialStatus: null },
};
expect(response.body).toEqual(expectedResponsePayload);
@@ -82,7 +82,7 @@ describe('graphQL getTrialStatus query', () => {
.expect(200);
const expectedResponsePayload = {
data: { getTrialStatus: null as string },
data: { getTrialStatus: null },
};
expect(response.body).toEqual(expectedResponsePayload);

View File

@@ -1,11 +1,11 @@
import request, { Test } from 'supertest';
// @ts-nocheck
import request from 'supertest';
import app from '../../app';
import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id';
import Crypto from 'crypto';
import { createRole } from '../../../test/factories/role';
import { createPermission } from '../../../test/factories/permission';
import { createUser } from '../../../test/factories/user';
import { IRole, IUser } from '@automatisch/types';
describe('graphQL getUser query', () => {
describe('with unauthenticated user', () => {
@@ -61,11 +61,7 @@ describe('graphQL getUser query', () => {
});
describe('and correct permissions', () => {
let role: IRole,
currentUser: IUser,
anotherUser: IUser,
token: string,
requestObject: Test;
let role, currentUser, anotherUser, token, requestObject;
beforeEach(async () => {
role = await createRole({
@@ -116,12 +112,12 @@ describe('graphQL getUser query', () => {
const expectedResponsePayload = {
data: {
getUser: {
createdAt: (anotherUser.createdAt as Date).getTime().toString(),
createdAt: anotherUser.createdAt.getTime().toString(),
email: anotherUser.email,
fullName: anotherUser.fullName,
id: anotherUser.id,
role: { id: role.id, name: role.name },
updatedAt: (anotherUser.updatedAt as Date).getTime().toString(),
updatedAt: anotherUser.updatedAt.getTime().toString(),
},
},
};

View File

@@ -1,10 +1,10 @@
import request, { Test } from 'supertest';
// @ts-nocheck
import request from 'supertest';
import app from '../../app';
import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id';
import { createRole } from '../../../test/factories/role';
import { createPermission } from '../../../test/factories/permission';
import { createUser } from '../../../test/factories/user';
import { IRole, IUser } from '@automatisch/types';
describe('graphQL getUsers query', () => {
const query = `
@@ -61,11 +61,7 @@ describe('graphQL getUsers query', () => {
});
describe('and with correct permissions', () => {
let role: IRole,
currentUser: IUser,
anotherUser: IUser,
token: string,
requestObject: Test;
let role, currentUser, anotherUser, token, requestObject;
beforeEach(async () => {
role = await createRole({

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import request from 'supertest';
import app from '../../app';
import appConfig from '../../config/app';

View File

@@ -20,7 +20,11 @@ type Query {
): FlowConnection
getStepWithTestExecutions(stepId: String!): [Step]
getExecution(executionId: String!): Execution
getExecutions(limit: Int!, offset: Int!): ExecutionConnection
getExecutions(
limit: Int!
offset: Int!
filters: ExecutionFiltersInput
): ExecutionConnection
getExecutionSteps(
executionId: String!
limit: Int!
@@ -795,6 +799,17 @@ type Notification {
description: String
}
input ExecutionCreatedAtFilterInput {
from: String
to: String
}
input ExecutionFiltersInput {
flowId: String
createdAt: ExecutionCreatedAtFilterInput
status: String
}
schema {
query: Query
mutation: Mutation

View File

@@ -275,7 +275,10 @@ class User extends Base {
async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
await super.$beforeUpdate(opt, queryContext);
this.email = this.email.toLowerCase();
if (this.email) {
this.email = this.email.toLowerCase();
}
await this.generateHash();
}

View File

@@ -10,6 +10,8 @@ export const createConnection = async (params: Partial<Connection> = {}) => {
authenticationKey: 'test key',
};
delete params.formattedData;
params.data = AES.encrypt(
JSON.stringify(formattedData),
appConfig.encryptionKey

View File

@@ -4,6 +4,8 @@ import { createFlow } from './flow';
export const createExecution = async (params: Partial<Execution> = {}) => {
params.flowId = params?.flowId || (await createFlow()).id;
params.testRun = params?.testRun || false;
params.createdAt = params?.createdAt || new Date().toISOString();
params.updatedAt = params?.updatedAt || new Date().toISOString();
const [execution] = await global.knex
.table('executions')

View File

@@ -4,6 +4,8 @@ import { createUser } from './user';
export const createFlow = async (params: Partial<Flow> = {}) => {
params.userId = params?.userId || (await createUser()).id;
params.name = params?.name || 'Name your flow!';
params.createdAt = params?.createdAt || new Date().toISOString();
params.updatedAt = params?.updatedAt || new Date().toISOString();
const [flow] = await global.knex.table('flows').insert(params).returning('*');

View File

@@ -1,24 +1,15 @@
import { IPermission } from '@automatisch/types';
import Permission from '../../src/models/permission';
import { createRole } from './role';
type PermissionParams = {
roleId?: string;
action?: string;
subject?: string;
};
export const createPermission = async (
params: PermissionParams = {}
): Promise<IPermission> => {
const permissionData = {
roleId: params?.roleId || (await createRole()).id,
action: params?.action || 'read',
subject: params?.subject || 'User',
};
export const createPermission = async (params: Partial<Permission> = {}) => {
params.roleId = params?.roleId || (await createRole()).id;
params.action = params?.action || 'read';
params.subject = params?.subject || 'User';
params.conditions = params?.conditions || ['isCreator'];
const [permission] = await global.knex
.table('permissions')
.insert(permissionData)
.insert(params)
.returning('*');
return permission;

View File

@@ -13,7 +13,9 @@ export const createStep = async (params: Partial<Step> = {}) => {
.first();
params.position = params?.position || (lastStep?.position || 0) + 1;
params.status = params?.status || 'incomplete';
params.status = params?.status || 'completed';
params.appKey =
params?.appKey || (params.type === 'action' ? 'deepl' : 'webhook');
const [step] = await global.knex.table('steps').insert(params).returning('*');

View File

@@ -188,6 +188,14 @@ export default defineConfig({
{ text: 'Connection', link: '/apps/mattermost/connection' },
],
},
{
text: 'Microsoft Teams',
collapsible: true,
collapsed: true,
items: [
{ text: 'Connection', link: '/apps/microsoft-teams/connection' },
],
},
{
text: 'Miro',
collapsible: true,
@@ -377,7 +385,10 @@ export default defineConfig({
text: 'Trello',
collapsible: true,
collapsed: true,
items: [{ text: 'Connection', link: '/apps/trello/connection' }],
items: [
{ text: 'Actions', link: '/apps/trello/actions' },
{ text: 'Connection', link: '/apps/trello/connection' },
],
},
{
text: 'Twilio',
@@ -435,6 +446,12 @@ export default defineConfig({
{ text: 'Connection', link: '/apps/youtube/connection' },
],
},
{
text: 'Zendesk',
collapsible: true,
collapsed: true,
items: [{ text: 'Connection', link: '/apps/zendesk/connection' }],
},
],
'/': [
{

View File

@@ -0,0 +1,28 @@
# Microsoft Teams
:::info
This page explains the steps you need to follow to set up the Microsoft Teams
connection in Automatisch. If any of the steps are outdated, please let us know!
:::
1. Sign in to the [Microsoft Entra admin center](https://entra.microsoft.com).
2. Click **Identity** from the menu on the left.
3. Expand the **Applications** and click **App Registrations**.
4. In this page, click on **New registrations**.
5. Fill in the **Name** field.
6. Select the **Accounts in any organizational directory** option.
7. In Redirect URI, select **Web** as platform.
8. Copy **OAuth Redirect URL** from Automatisch to the **Redirect URI** field.
9. Click on the **Register** button at the end of the form.
10. Go to the **Authentication** tab and select **Access tokens (used for implicit flows)** in the **Implicit grant and hybrid flows** section.
11. Click on the **Save** button.
12. Go to the **Overview** tab.
13. Copy the **Application (client) ID** value to the `Client ID` field on Automatisch.
14. In the same page, click on the **Add a certificate or secret** link.
15. Click on the **New client secret** button.
16. Fill in the **Description**, **Expires**, **Start**, and **End** fields.
17. It is important to note that you need to reconnect your connection manually once the client secret expires.
18. and click on the **Add** button.
19. Copy the **Client Secret** value to the `Client Secret` field on Automatisch.
20. Click **Submit** button on Automatisch.
21. Congrats! Start using your new Microsoft Teams connection within the flows.

View File

@@ -0,0 +1,12 @@
---
favicon: /favicons/trello.svg
items:
- name: Create card
desc: Creates a new card within a specified board and list.
---
<script setup>
import CustomListing from '../../components/CustomListing.vue'
</script>
<CustomListing />

View File

@@ -0,0 +1,21 @@
# Zendesk
:::info
This page explains the steps you need to follow to set up the Zendesk
connection in Automatisch. If any of the steps are outdated, please let us know!
:::
1. Fill `Zendesk Subdomain URL` with your dashboard URL, for example: `https://yourcompany.zendesk.com`.
2. Go to your Zendesk dashboard.
3. Click on **Zendesk Products** at the top right corner and click **Admin Center** from the dropdown.
4. Enter **App and integrations** section.
5. Click on **Zendesk API** from the sidebar.
6. Click on **OAuth Clients** tab.
7. Click on **Add OAuth Client** button.
8. Enter necessary information in the form.
9. Copy **OAuth Redirect URL** from Automatisch to **Redirect URLs** field in the form.
10. Enter your preferred client ID value in **Unique Identifier** field.
11. Save the form to complete creating the OAuth client.
12. Copy the `Unique identifier` value from the page to the `Client ID` field on Automatisch.
13. Copy the `Secret` value from the page to the `Client Secret` field on Automatisch.
14. Now, you can start using the Zendesk connection with Automatisch.

View File

@@ -39,6 +39,7 @@ The following integrations are currently supported by Automatisch.
- [Stripe](/apps/stripe/triggers)
- [Telegram](/apps/telegram-bot/actions)
- [Todoist](/apps/todoist/triggers)
- [Trello](/apps/trello/actions)
- [Twilio](/apps/twilio/triggers)
- [Twitter](/apps/twitter/triggers)
- [Typeform](/apps/typeform/triggers)

View File

@@ -0,0 +1,64 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024" width="1024" height="1024" >
<defs>
<linearGradient id="plate-fill" x1="-.2" y1="-.2" x2=".8" y2=".8">
<stop offset="0" stop-color="#5a62c4"></stop>
<stop offset="1" stop-color="#3940ab"></stop>
</linearGradient>
<style>
.cls-1{fill:#5059c9}.cls-2{fill:#7b83eb}
</style>
<filter id="person-shadow" x="-50%" y="-50%" width="300%" height="300%">
<feGaussianBlur in="SourceAlpha" stdDeviation="25"></feGaussianBlur>
<feOffset dy="25"></feOffset>
<feComponentTransfer>
<feFuncA type="linear" slope=".25"></feFuncA>
</feComponentTransfer>
<feMerge>
<feMergeNode></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<filter id="back-plate-shadow" x="-50%" y="-50%" width="300%" height="300%">
<feGaussianBlur in="SourceAlpha" stdDeviation="24"></feGaussianBlur>
<feOffset dx="2" dy="24"></feOffset>
<feComponentTransfer>
<feFuncA type="linear" slope=".6"></feFuncA>
</feComponentTransfer>
<feMerge>
<feMergeNode></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<filter id="tee-shadow" x="-50%" y="-50%" width="250%" height="250%">
<feGaussianBlur in="SourceAlpha" stdDeviation="12"></feGaussianBlur>
<feOffset dx="10" dy="20"></feOffset>
<feComponentTransfer>
<feFuncA type="linear" slope=".2"></feFuncA>
</feComponentTransfer>
<feMerge>
<feMergeNode></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<clipPath id="back-plate-clip">
<path d="M684 432H512v-49.143A112 112 0 1 0 416 272a111.556 111.556 0 0 0 10.785 48H160a32.094 32.094 0 0 0-32 32v320a32.094 32.094 0 0 0 32 32h178.67c15.236 90.8 94.2 160 189.33 160 106.039 0 192-85.961 192-192V468a36 36 0 0 0-36-36z" fill="#fff"></path>
</clipPath>
</defs>
<g id="small_person" filter="url(#person-shadow)">
<path id="Body" class="cls-1" d="M692 432h168a36 36 0 0 1 36 36v164a120 120 0 0 1-120 120 120 120 0 0 1-120-120V468a36 36 0 0 1 36-36z"></path>
<circle id="Head" class="cls-1" cx="776" cy="304" r="80"></circle>
</g>
<g id="Large_Person" filter="url(#person-shadow)">
<path id="Body-2" data-name="Body" class="cls-2" d="M372 432h312a36 36 0 0 1 36 36v204a192 192 0 0 1-192 192 192 192 0 0 1-192-192V468a36 36 0 0 1 36-36z"></path>
<circle id="Head-2" data-name="Head" class="cls-2" cx="528" cy="272" r="112"></circle>
</g>
<rect id="Back_Plate" x="128" y="320" width="384" height="384" rx="32" ry="32" filter="url(#back-plate-shadow)" clip-path="url(#back-plate-clip)" fill="url(#plate-fill)"></rect>
<path id="Letter_T" d="M399.365 445.855h-60.293v164.2h-38.418v-164.2h-60.02V414h158.73z" filter="url(#tee-shadow)" fill="#fff"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="363" height="259" fill="#03363d"><path d="M173.82 40.5v112.86H80.34L173.82 40.5zm0-40.5a46.74 46.74 0 1 1-93.48 0h93.48zm15.4 153.37a46.74 46.74 0 0 1 93.48 0h-93.48zm0-40.5V0h93.5l-93.5 112.86zm52.28 137.06a18.22 18.22 0 0 0 12.95-5l6.42 6.93c-4.24 4.36-10.12 7.6-19.26 7.6-15.67 0-25.8-10.4-25.8-24.46a24 24 0 0 1 24.37-24.47c15.56 0 24.38 11.84 23.6 28.26H227c1.3 6.82 6.1 11.17 14.47 11.17m11.2-19c-1-6.37-4.8-11.06-12.4-11.06-7.07 0-12 4-13.27 11.06h25.68zM0 249.4l28.3-28.76H.67v-9.02h40.76v9.2l-28.3 28.75h28.7v9.03H0v-9.2zm73.6.52a18.22 18.22 0 0 0 12.95-5l6.42 6.93c-4.24 4.36-10.12 7.6-19.26 7.6-15.67 0-25.8-10.4-25.8-24.46a24 24 0 0 1 24.37-24.47c15.56 0 24.38 11.84 23.6 28.26H59.12c1.3 6.82 6.1 11.17 14.47 11.17m11.2-19c-1-6.37-4.8-11.06-12.4-11.06-7.07 0-12 4-13.27 11.06H84.8zm72.23 4.03c0-15 11.23-24.44 23.6-24.44a20.34 20.34 0 0 1 15.67 7.05v-27.72h10v68.6h-10V252a20.1 20.1 0 0 1-15.76 7.42c-12 0-23.5-9.5-23.5-24.43m39.82-.1a14.92 14.92 0 1 0-14.91 15.32c8.6 0 14.9-6.86 14.9-15.32m73.48 13.6l9.06-4.7a13.44 13.44 0 0 0 12.08 6.86c5.66 0 8.6-2.9 8.6-6.2 0-3.76-5.47-4.6-11.42-5.83-8-1.7-16.33-4.33-16.33-14 0-7.43 7.07-14.3 18.2-14.2 8.77 0 15.3 3.48 19 9.1l-8.4 4.6a12.19 12.19 0 0 0-10.57-5.36c-5.38 0-8.12 2.63-8.12 5.64 0 3.38 4.34 4.32 11.14 5.83 7.74 1.7 16.5 4.23 16.5 14 0 6.48-5.66 15.22-19.06 15.13-9.8 0-16.7-3.95-20.67-10.9m66.9-10.87l-7.93 8.65v12.2h-10v-68.6h10v44.93l21.23-23.3h12.18l-18.4 20.1 18.88 26.88h-11.32l-14.63-20.86zM126.8 210.53c-11.9 0-21.85 7.7-21.85 20.5v27.45h10.2V232.3c0-7.7 4.43-12.32 12-12.32s11.33 4.6 11.33 12.32v26.18h10.14v-27.45c0-12.78-10-20.5-21.85-20.5"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,106 @@
const { AuthenticatedPage } = require('../authenticated-page');
const { RoleConditionsModal } = require('./role-conditions-modal');
export class AdminCreateRolePage extends AuthenticatedPage {
screenshotPath = '/admin/create-role'
/**
* @param {import('@playwright/test').Page} page
*/
constructor (page) {
super(page);
this.nameInput = page.getByTestId('name-input');
this.descriptionInput = page.getByTestId('description-input');
this.createButton = page.getByTestId('create-button');
this.connectionRow = page.getByTestId('Connection-permission-row');
this.executionRow = page.getByTestId('Execution-permission-row');
this.flowRow = page.getByTestId('Flow-permission-row');
}
/**
* @param {('Connection'|'Execution'|'Flow')} subject
*/
getRoleConditionsModal (subject) {
return new RoleConditionsModal(this.page, subject);
}
async getPermissionConfigs () {
const subjects = ['Connection', 'Flow', 'Execution'];
const permissionConfigs = [];
for (let subject of subjects) {
const row = this.getSubjectRow(subject);
const actionInputs = await this.getRowInputs(row);
Object.keys(actionInputs).forEach(action => {
permissionConfigs.push({
action,
locator: actionInputs[action],
subject,
row
});
});
}
return permissionConfigs;
}
/**
*
* @param {(
* 'Connection' | 'Flow' | 'Execution'
* )} subject
*/
getSubjectRow (subject) {
const k = `${subject.toLowerCase()}Row`
if (this[k]) {
return this[k]
} else {
throw 'Unknown row'
}
}
/**
* @param {import('@playwright/test').Locator} row
*/
async getRowInputs (row) {
const inputs = {
// settingsButton: row.getByTestId('permission-settings-button')
}
for (let input of ['create', 'read', 'update', 'delete', 'publish']) {
const testId = `${input}-checkbox`
if (await row.getByTestId(testId).count() > 0) {
inputs[input] = row.getByTestId(testId).locator('input');
}
}
return inputs
}
/**
* @param {import('@playwright/test').Locator} row
*/
async clickPermissionSettings (row) {
await row.getByTestId('permission-settings-button').click();
}
/**
*
* @param {string} subject
* @param {'create'|'read'|'update'|'delete'|'publish'} action
* @param {boolean} val
*/
async updateAction (subject, action, val) {
const row = await this.getSubjectRow(subject);
const inputs = await this.getRowInputs(row);
if (inputs[action]) {
if (await inputs[action].isChecked()) {
if (!val) {
await inputs[action].click();
}
} else {
if (val) {
await inputs[action].click();
}
}
} else {
throw new Error(`${subject} does not have action ${action}`)
}
}
}

View File

@@ -0,0 +1,30 @@
const { faker } = require('@faker-js/faker');
const { AuthenticatedPage } = require('../authenticated-page');
export class AdminCreateUserPage extends AuthenticatedPage {
screenshot = '/admin/create-user';
/**
* @param {import('@playwright/test').Page} page
*/
constructor (page) {
super(page);
this.fullNameInput = page.getByTestId('full-name-input');
this.emailInput = page.getByTestId('email-input');
this.passwordInput = page.getByTestId('password-input');
this.roleInput = page.getByTestId('role.id-autocomplete');
this.createButton = page.getByTestId('create-button');
}
seed (seed) {
faker.seed(seed || 0);
}
generateUser () {
return {
fullName: faker.person.fullName(),
email: faker.internet.email().toLowerCase(),
password: faker.internet.password()
}
}
}

View File

@@ -0,0 +1,19 @@
export class DeleteRoleModal {
screenshotPath = '/admin/delete-role-modal';
/**
* @param {import('@playwright/test').Page} page
*/
constructor (page) {
this.page = page;
this.modal = page.getByTestId('delete-role-modal');
this.cancelButton = this.modal.getByTestId('confirmation-cancel-button');
this.deleteButton = this.modal.getByTestId('confirmation-confirm-button');
}
async close () {
await this.page.click('body', {
position: { x: 10, y: 10 }
});
}
}

View File

@@ -0,0 +1,19 @@
export class DeleteUserModal {
screenshotPath = '/admin/delete-modal';
/**
* @param {import('@playwright/test').Page} page
*/
constructor (page) {
this.page = page;
this.modal = page.getByTestId('delete-user-modal');
this.cancelButton = this.modal.getByTestId('confirmation-cancel-button');
this.deleteButton = this.modal.getByTestId('confirmation-confirm-button');
}
async close () {
await this.page.click('body', {
position: { x: 10, y: 10 }
})
}
}

View File

@@ -0,0 +1,9 @@
const { AdminCreateRolePage } = require('./create-role-page')
export class AdminEditRolePage extends AdminCreateRolePage {
constructor (page) {
super(page);
delete this.createButton;
this.updateButton = page.getByTestId('update-button');
}
}

View File

@@ -0,0 +1,26 @@
const { faker } = require('@faker-js/faker');
const { AuthenticatedPage } = require('../authenticated-page');
faker.seed(9002);
export class AdminEditUserPage extends AuthenticatedPage {
screenshot = '/admin/edit-user';
/**
* @param {import('@playwright/test').Page} page
*/
constructor (page) {
super(page);
this.fullNameInput = page.getByTestId('full-name-input');
this.emailInput = page.getByTestId('email-input');
this.roleInput = page.getByTestId('role.id-autocomplete');
this.updateButton = page.getByTestId('update-button');
}
generateUser () {
return {
fullName: faker.person.fullName(),
email: faker.internet.email(),
}
}
}

View File

@@ -0,0 +1,29 @@
const { AdminCreateUserPage } = require('./create-user-page');
const { AdminEditUserPage } = require('./edit-user-page');
const { AdminUsersPage } = require('./users-page');
const { AdminRolesPage } = require('./roles-page');
const { AdminCreateRolePage } = require('./create-role-page');
const { AdminEditRolePage } = require('./edit-role-page');
export const adminFixtures = {
adminUsersPage: async ({ page }, use) => {
await use(new AdminUsersPage(page));
},
adminCreateUserPage: async ({ page }, use) => {
await use(new AdminCreateUserPage(page));
},
adminEditUserPage: async ({page}, use) => {
await use(new AdminEditUserPage(page));
},
adminRolesPage: async ({ page}, use) => {
await use(new AdminRolesPage(page));
},
adminEditRolePage: async ({ page}, use) => {
await use(new AdminEditRolePage(page));
},
adminCreateRolePage: async ({ page}, use) => {
await use(new AdminCreateRolePage(page));
},
}

View File

@@ -0,0 +1,47 @@
export class RoleConditionsModal {
/**
* @param {import('@playwright/test').Page} page
* @param {('Connection'|'Execution'|'Flow')} subject
*/
constructor (page, subject) {
this.page = page;
this.modal = page.getByTestId(`${subject}-role-conditions-modal`);
this.modalBody = this.modal.getByTestId('role-conditions-modal-body');
this.createCheckbox = this.modal.getByTestId(
'isCreator-create-checkbox'
).locator('input');
this.readCheckbox = this.modal.getByTestId(
'isCreator-read-checkbox'
).locator('input');
this.updateCheckbox = this.modal.getByTestId(
'isCreator-update-checkbox'
).locator('input');
this.deleteCheckbox = this.modal.getByTestId(
'isCreator-delete-checkbox'
).locator('input');
this.publishCheckbox = this.modal.getByTestId(
'isCreator-publish-checkbox'
).locator('input');
this.applyButton = this.modal.getByTestId('confirmation-confirm-button');
this.cancelButton = this.modal.getByTestId('confirmation-cancel-button');
}
async getAvailableConditions () {
let conditions = {};
const actions = ['create', 'read', 'update', 'delete', 'publish'];
for (let action of actions) {
const locator = this[`${action}Checkbox`];
if (locator && await locator.count() > 0) {
conditions[action] = locator;
}
}
return conditions;
}
async close () {
await this.page.click('body', {
position: { x: 10, y: 10 }
});
}
}

View File

@@ -0,0 +1,79 @@
const { AuthenticatedPage } = require('../authenticated-page');
const { DeleteRoleModal } = require('./delete-role-modal')
export class AdminRolesPage extends AuthenticatedPage {
screenshotPath = '/admin-roles';
/**
* @param {import('@playwright/test').Page} page
*/
constructor (page) {
super(page);
this.roleDrawerLink = page.getByTestId('roles-drawer-link');
this.createRoleButton = page.getByTestId('create-role');
this.deleteRoleModal = new DeleteRoleModal(page);
this.roleRow = page.getByTestId('role-row');
this.rolesLoader = page.getByTestId('roles-list-loader');
}
/**
*
* @param {boolean} isMobile - navigation on smaller devices requires the
* user to open up the drawer menu
*/
async navigateTo (isMobile=false) {
await this.profileMenuButton.click();
await this.adminMenuItem.click();
if (isMobile) {
await this.drawerMenuButton.click();
}
await this.roleDrawerLink.click();
}
/**
* @param {string} name
*/
async getRoleRowByName (name) {
return this.roleRow.filter({
has: this.page.getByTestId('role-name').filter({
hasText: name
})
});
}
/**
* @param {import('@playwright/test').Locator} row
*/
async getRowData (row) {
return {
role: await row.getByTestId('role-name').textContent(),
description: await row.getByTestId('role-description').textContent(),
canEdit: await row.getByTestId(
'role-edit'
).isEnabled(),
canDelete: await row.getByTestId(
'role-delete'
).isEnabled()
}
}
/**
* @param {import('@playwright/test').Locator} row
*/
async clickEditRole (row) {
await row.getByTestId('role-edit').click();
}
/**
* @param {import('@playwright/test').Locator} row
*/
async clickDeleteRole (row) {
await row.getByTestId('role-delete').click();
return this.deleteRoleModal;
}
async editRole (subject) {
const row = await this.getRoleRowByName(subject);
await this.clickEditRole(row);
}
}

View File

@@ -0,0 +1,131 @@
const { faker } = require('@faker-js/faker');
const { AuthenticatedPage } = require('../authenticated-page');
const { DeleteUserModal } = require('./delete-user-modal');
faker.seed(9001);
export class AdminUsersPage extends AuthenticatedPage {
screenshotPath = '/admin';
/**
* @param {import('@playwright/test').Page} page
*/
constructor (page) {
super(page);
this.createUserButton = page.getByTestId('create-user');
this.userRow = page.getByTestId('user-row');
this.deleteUserModal = new DeleteUserModal(page);
this.firstPageButton = page.getByTestId('first-page-button');
this.previousPageButton = page.getByTestId('previous-page-button');
this.nextPageButton = page.getByTestId('next-page-button');
this.lastPageButton = page.getByTestId('last-page-button');
this.usersLoader = page.getByTestId('users-list-loader');
}
async navigateTo () {
await this.profileMenuButton.click();
await this.adminMenuItem.click();
if (await this.usersLoader.isVisible()) {
await this.usersLoader.waitFor({
state: 'detached'
});
}
}
/**
* @param {string} email
*/
async getUserRowByEmail (email) {
return this.userRow.filter({
has: this.page.getByTestId('user-email').filter({
hasText: email
})
});
}
/**
* @param {import('@playwright/test').Locator} row
*/
async getRowData (row) {
return {
fullName: await row.getByTestId('user-full-name').textContent(),
email: await row.getByTestId('user-email').textContent(),
role: await row.getByTestId('user-role').textContent()
}
}
/**
* @param {import('@playwright/test').Locator} row
*/
async clickEditUser (row) {
await row.getByTestId('user-edit').click();
}
/**
* @param {import('@playwright/test').Locator} row
*/
async clickDeleteUser (row) {
await row.getByTestId('delete-button').click();
return this.deleteUserModal;
}
/**
* @param {string} email
* @returns {import('@playwright/test').Locator | null}
*/
async findUserPageWithEmail (email) {
if (await this.usersLoader.isVisible()) {
await this.usersLoader.waitFor({
state: 'detached'
});
}
// start at the first page
const firstPageDisabled = await this.firstPageButton.isDisabled();
if (!firstPageDisabled) {
await this.firstPageButton.click();
}
while (true) {
if (await this.usersLoader.isVisible()) {
await this.usersLoader.waitFor({
state: 'detached'
});
}
const rowLocator = await this.getUserRowByEmail(email);
if ((await rowLocator.count()) === 1) {
return rowLocator;
}
if (await this.nextPageButton.isDisabled()) {
return null;
} else {
await this.nextPageButton.click();
}
}
}
async getTotalRows () {
return await this.page.evaluate(() => {
const node = document.querySelector('[data-total-count]');
if (node) {
const count = Number(node.dataset.totalCount);
if (!isNaN(count)) {
return count;
}
}
return 0;
});
}
async getRowsPerPage () {
return await this.page.evaluate(() => {
const node = document.querySelector('[data-rows-per-page]');
if (node) {
const count = Number(node.dataset.rowsPerPage);
if (!isNaN(count)) {
return count;
}
}
return 0;
});
}
}

View File

@@ -13,9 +13,9 @@ export class ApplicationsModal extends BasePage {
constructor (page) {
super(page);
this.modal = page.getByTestId('add-app-connection-dialog');
this.searchInput = page.getByTestId('search-for-app-text-field');
this.appListItem = page.getByTestId('app-list-item');
this.appLoader = page.getByTestId('search-for-app-loader');
this.searchInput = this.modal.getByTestId('search-for-app-text-field');
this.appListItem = this.modal.getByTestId('app-list-item');
this.appLoader = this.modal.getByTestId('search-for-app-loader');
}
/**

View File

@@ -14,6 +14,7 @@ export class AuthenticatedPage extends BasePage {
this.adminMenuItem = this.page.getByRole('menuitem', { name: 'Admin' });
this.userInterfaceDrawerItem = this.page.getByTestId('user-interface-drawer-link');
this.appBar = this.page.getByTestId('app-bar');
this.drawerMenuButton = this.page.getByTestId('drawer-menu-button');
this.goToDashboardButton = this.page.getByTestId('go-back-drawer-link');
this.typographyLogo = this.page.getByTestId('typography-logo');
this.customLogo = this.page.getByTestId('custom-logo');

View File

@@ -1,5 +1,11 @@
const path = require('node:path');
/**
* @typedef {(
* 'default' | 'success' | 'warning' | 'error' | 'info'
* )} SnackbarVariant - Snackbar variant types in notistack/v3, see https://notistack.com/api-reference
*/
export class BasePage {
screenshotPath = '/';
@@ -8,7 +14,53 @@ export class BasePage {
*/
constructor(page) {
this.page = page;
this.snackbar = this.page.locator('#notistack-snackbar');
this.snackbar = page.locator('*[data-test^="snackbar"]');
}
/**
* Finds the latest snackbar message and extracts relevant data
* @param {string | undefined} testId
* @returns {(
* null | {
* variant: SnackbarVariant,
* text: string,
* dataset: { [key: string]: string }
* }
* )}
*/
async getSnackbarData (testId) {
if (!testId) {
testId = 'snackbar';
}
const snack = this.page.getByTestId(testId);
return {
variant: await snack.getAttribute('data-snackbar-variant'),
text: await snack.evaluate(node => node.innerText),
dataset: await snack.evaluate(node => {
function getChildren (n) {
return [n].concat(
...Array.from(n.children).map(c => getChildren(c))
);
}
const datasets = getChildren(node).map(
n => Object.assign({}, n.dataset)
);
return Object.assign({}, ...datasets);
})
};
}
/**
* Closes all snackbars, should be replaced later
*/
async closeSnackbar () {
const snackbars = await this.snackbar.all();
for (const snackbar of snackbars) {
await snackbar.click();
}
for (const snackbar of snackbars) {
await snackbar.waitFor({ state: 'detached' });
}
}
async clickAway() {

View File

@@ -5,6 +5,7 @@ const { ExecutionsPage } = require('./executions-page');
const { FlowEditorPage } = require('./flow-editor-page');
const { UserInterfacePage } = require('./user-interface-page');
const { LoginPage } = require('./login-page');
const { adminFixtures } = require('./admin');
exports.test = test.extend({
page: async ({ page }, use) => {
@@ -31,6 +32,7 @@ exports.test = test.extend({
userInterfacePage: async ({ page }, use) => {
await use(new UserInterfacePage(page));
},
...adminFixtures
});
exports.publicTest = test.extend({

View File

@@ -1,9 +1,19 @@
const path = require('node:path');
const { expect } = require('@playwright/test');
const { BasePage } = require('./base-page');
export class LoginPage extends BasePage {
path = '/login';
static defaultEmail = process.env.LOGIN_EMAIL;
static defaultPassword = process.env.LOGIN_PASSWORD;
static setDefaultLogin (email, password) {
this.defaultEmail = email;
this.defaultPassword = password;
}
static resetDefaultLogin () {
this.defaultEmail = process.env.LOGIN_EMAIL;
this.defaultPassword = process.env.LOGIN_PASSWORD;
}
/**
* @param {import('@playwright/test').Page} page
@@ -22,8 +32,8 @@ export class LoginPage extends BasePage {
}
async login(
email = process.env.LOGIN_EMAIL,
password = process.env.LOGIN_PASSWORD
email = LoginPage.defaultEmail,
password = LoginPage.defaultPassword
) {
await this.page.goto(this.path);
await this.emailTextField.fill(email);

View File

@@ -24,10 +24,17 @@
"url": "https://github.com/automatisch/automatisch/issues"
},
"devDependencies": {
"@faker-js/faker": "^8.2.0",
"@playwright/test": "^1.36.2"
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^5.9.1",
"@typescript-eslint/parser": "^5.9.1",
"dotenv": "^16.3.1",
"micro": "^10.0.1"
"eslint": "^8.13.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"micro": "^10.0.1",
"prettier": "^2.5.1"
}
}

View File

@@ -0,0 +1,458 @@
const { test, expect } = require('../../fixtures/index');
const { LoginPage } = require('../../fixtures/login-page');
test.describe('Role management page', () => {
test.skip('Admin role is not deletable', async ({ adminRolesPage }) => {
await adminRolesPage.navigateTo();
const adminRow = await adminRolesPage.getRoleRowByName('Admin');
const rowCount = await adminRow.count();
await expect(rowCount).toBe(1);
const data = await adminRolesPage.getRowData(adminRow);
await expect(data.role).toBe('Admin');
await expect(data.canEdit).toBe(true);
await expect(data.canDelete).toBe(false);
});
test('Can create, edit, and delete a role', async ({
adminCreateRolePage,
adminEditRolePage,
adminRolesPage,
page,
}) => {
await test.step('Create a new role', async () => {
await adminRolesPage.navigateTo();
await adminRolesPage.createRoleButton.click();
await adminCreateRolePage.nameInput.fill('Create Edit Test');
await adminCreateRolePage.descriptionInput.fill('Test description');
await adminCreateRolePage.createButton.click();
await adminCreateRolePage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminCreateRolePage.getSnackbarData(
'snackbar-create-role-success'
);
await expect(snackbar.variant).toBe('success');
await adminCreateRolePage.closeSnackbar();
});
let roleRow = await test.step(
'Make sure role data is correct',
async () => {
const roleRow = await adminRolesPage.getRoleRowByName(
'Create Edit Test'
);
const rowCount = await roleRow.count();
await expect(rowCount).toBe(1);
const roleData = await adminRolesPage.getRowData(roleRow);
await expect(roleData.role).toBe('Create Edit Test');
await expect(roleData.description).toBe('Test description');
await expect(roleData.canEdit).toBe(true);
await expect(roleData.canDelete).toBe(true);
return roleRow;
}
);
await test.step('Edit the role', async () => {
await adminRolesPage.clickEditRole(roleRow);
await adminEditRolePage.nameInput.fill('Create Update Test');
await adminEditRolePage.descriptionInput.fill('Update test description');
await adminEditRolePage.updateButton.click();
await adminEditRolePage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminEditRolePage.getSnackbarData(
'snackbar-edit-role-success'
);
await expect(snackbar.variant).toBe('success');
await adminEditRolePage.closeSnackbar();
});
roleRow = await test.step(
'Make sure changes reflected on roles page',
async () => {
const roleRow = await adminRolesPage.getRoleRowByName(
'Create Update Test'
);
const rowCount = await roleRow.count();
await expect(rowCount).toBe(1);
const roleData = await adminRolesPage.getRowData(roleRow);
await expect(roleData.role).toBe('Create Update Test');
await expect(roleData.description).toBe('Update test description');
await expect(roleData.canEdit).toBe(true);
await expect(roleData.canDelete).toBe(true);
return roleRow;
}
);
await test.step('Delete the role', async () => {
await adminRolesPage.clickDeleteRole(roleRow);
const deleteModal = adminRolesPage.deleteRoleModal;
await deleteModal.modal.waitFor({
state: 'attached',
});
await deleteModal.deleteButton.click();
await adminRolesPage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminRolesPage.getSnackbarData(
'snackbar-delete-role-success'
);
await expect(snackbar.variant).toBe('success');
await adminRolesPage.closeSnackbar();
await deleteModal.modal.waitFor({
state: 'detached',
});
const rowCount = await roleRow.count();
await expect(rowCount).toBe(0);
});
});
// This test breaks right now
test.skip('Make sure create/edit role page is scrollable', async ({
adminRolesPage,
page,
}) => {
const initViewportSize = page.viewportSize;
await page.setViewportSize({
width: 800,
height: 400,
});
await test.step('Ensure create role page is scrollable', async () => {
await adminRolesPage.navigateTo(true);
await adminRolesPage.createRoleButton.click();
const initScrollTop = await page.evaluate(() => {
return document.documentElement.scrollTop;
});
await page.mouse.move(400, 100);
await page.mouse.click(400, 100);
await page.mouse.wheel(200, 0);
const updatedScrollTop = await page.evaluate(() => {
return document.documentElement.scrollTop;
});
await expect(initScrollTop).not.toBe(updatedScrollTop);
});
await test.step('Ensure edit role page is scrollable', async () => {
await adminRolesPage.navigateTo(true);
const adminRow = await adminRolesPage.getRoleRowByName('Admin');
await adminRolesPage.clickEditRole(adminRow);
const initScrollTop = await page.evaluate(() => {
return document.documentElement.scrollTop;
});
await page.mouse.move(400, 100);
await page.mouse.wheel(200, 0);
const updatedScrollTop = await page.evaluate(() => {
return document.documentElement.scrollTop;
});
await expect(initScrollTop).not.toBe(updatedScrollTop);
});
await test.step('Reset viewport', async () => {
await page.setViewportSize(initViewportSize);
});
});
test('Cannot delete a role with a user attached to it', async ({
adminCreateRolePage,
adminRolesPage,
adminUsersPage,
adminCreateUserPage,
adminEditUserPage,
page,
}) => {
await adminRolesPage.navigateTo();
await test.step('Create a new role', async () => {
await adminRolesPage.createRoleButton.click();
await adminCreateRolePage.nameInput.fill('Delete Role');
await adminCreateRolePage.createButton.click();
await adminCreateRolePage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminCreateRolePage.getSnackbarData(
'snackbar-create-role-success'
);
await expect(snackbar.variant).toBe('success');
await adminCreateRolePage.closeSnackbar();
});
await test.step(
'Create a new user with the "Delete Role" role',
async () => {
await adminUsersPage.navigateTo();
await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill('User Role Test');
await adminCreateUserPage.emailInput.fill(
'user-role-test@automatisch.io'
);
await adminCreateUserPage.passwordInput.fill('sample');
await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page
.getByRole('option', { name: 'Delete Role' })
.click();
await adminCreateUserPage.createButton.click();
await adminUsersPage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminUsersPage.getSnackbarData(
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
}
);
await test.step(
'Try to delete "Delete Role" role when new user has it',
async () => {
await adminRolesPage.navigateTo();
const row = await adminRolesPage.getRoleRowByName('Delete Role');
const modal = await adminRolesPage.clickDeleteRole(row);
await modal.deleteButton.click();
await adminRolesPage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminRolesPage.getSnackbarData('snackbar-error');
await expect(snackbar.variant).toBe('error');
await adminRolesPage.closeSnackbar();
await modal.close();
}
);
await test.step('Change the role the user has', async () => {
await adminUsersPage.navigateTo();
await adminUsersPage.usersLoader.waitFor({
state: 'detached',
});
const row = await adminUsersPage.findUserPageWithEmail(
'user-role-test@automatisch.io'
);
await adminUsersPage.clickEditUser(row);
await adminEditUserPage.roleInput.click();
await adminEditUserPage.page
.getByRole('option', { name: 'Admin' })
.click();
await adminEditUserPage.updateButton.click();
await adminEditUserPage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminEditUserPage.getSnackbarData(
'snackbar-edit-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminEditUserPage.closeSnackbar();
});
await test.step('Delete the original role', async () => {
await adminRolesPage.navigateTo();
const row = await adminRolesPage.getRoleRowByName('Delete Role');
const modal = await adminRolesPage.clickDeleteRole(row);
await expect(modal.modal).toBeVisible();
await modal.deleteButton.click();
await adminRolesPage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminRolesPage.getSnackbarData(
'snackbar-delete-role-success'
);
await expect(snackbar.variant).toBe('success');
await adminRolesPage.closeSnackbar();
});
});
test('Deleting a role after deleting a user with that role', async ({
adminCreateRolePage,
adminRolesPage,
adminUsersPage,
adminCreateUserPage,
page,
}) => {
await adminRolesPage.navigateTo();
await test.step('Create a new role', async () => {
await adminRolesPage.createRoleButton.click();
await adminCreateRolePage.nameInput.fill('Cannot Delete Role');
await adminCreateRolePage.createButton.click();
await adminCreateRolePage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminCreateRolePage.getSnackbarData(
'snackbar-create-role-success'
);
await expect(snackbar.variant).toBe('success');
await adminCreateRolePage.closeSnackbar();
});
await test.step('Create a new user with this role', async () => {
await adminUsersPage.navigateTo();
await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill('User Delete Role Test');
await adminCreateUserPage.emailInput.fill(
'user-delete-role-test@automatisch.io'
);
await adminCreateUserPage.passwordInput.fill('sample');
await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page
.getByRole('option', { name: 'Cannot Delete Role' })
.click();
await adminCreateUserPage.createButton.click();
await adminCreateUserPage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminCreateUserPage.getSnackbarData(
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminCreateUserPage.closeSnackbar();
});
await test.step('Delete this user', async () => {
await adminUsersPage.navigateTo();
const row = await adminUsersPage.findUserPageWithEmail(
'user-delete-role-test@automatisch.io'
);
const modal = await adminUsersPage.clickDeleteUser(row);
await modal.deleteButton.click();
await adminUsersPage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminUsersPage.getSnackbarData(
'snackbar-delete-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
});
await test.step('Try deleting this role', async () => {
await adminRolesPage.navigateTo();
const row = await adminRolesPage.getRoleRowByName('Cannot Delete Role');
const modal = await adminRolesPage.clickDeleteRole(row);
await modal.deleteButton.click();
await adminRolesPage.snackbar.waitFor({
state: 'attached',
});
/*
* TODO: await snackbar - make assertions based on product
* decisions
const snackbar = await adminRolesPage.getSnackbarData();
await expect(snackbar.variant).toBe('...');
*/
await adminRolesPage.closeSnackbar();
});
});
});
test('Accessibility of role management page', async ({
page,
adminUsersPage,
adminCreateUserPage,
adminEditUserPage,
adminRolesPage,
adminCreateRolePage,
}) => {
test.slow();
await test.step('Create the basic test role', async () => {
await adminRolesPage.navigateTo();
await adminRolesPage.createRoleButton.click();
await adminCreateRolePage.nameInput.fill('Basic Test');
await adminCreateRolePage.createButton.click();
await adminCreateRolePage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminCreateRolePage.getSnackbarData(
'snackbar-create-role-success'
);
await expect(snackbar.variant).toBe('success');
await adminCreateRolePage.closeSnackbar();
});
await test.step('Create a new user with the basic role', async () => {
await adminUsersPage.navigateTo();
await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill('Role Test');
await adminCreateUserPage.emailInput.fill('basic-role-test@automatisch.io');
await adminCreateUserPage.passwordInput.fill('sample');
await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page
.getByRole('option', { name: 'Basic Test' })
.click();
await adminCreateUserPage.createButton.click();
await adminCreateUserPage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminCreateUserPage.getSnackbarData(
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminCreateRolePage.closeSnackbar();
});
await test.step('Logout and login to the basic role user', async () => {
await page.getByTestId('profile-menu-button').click();
await page.getByTestId('logout-item').click();
// await page.reload({ waitUntil: 'networkidle' });
const loginPage = new LoginPage(page);
await loginPage.login('basic-role-test@automatisch.io', 'sample');
await expect(loginPage.loginButton).not.toBeVisible();
await expect(page).toHaveURL('/flows');
});
await test.step(
'Navigate to the admin settings page and make sure it is blank',
async () => {
const pageUrl = new URL(page.url());
const url = `${pageUrl.origin}/admin-settings/users`;
await page.goto(url);
await page.waitForTimeout(750);
const isUnmounted = await page.evaluate(() => {
const root = document.querySelector('#root');
if (root) {
return root.children.length === 0;
}
return false;
});
await expect(isUnmounted).toBe(true);
}
);
await test.step('Log back into the admin account', async () => {
await page.goto('/');
await page.getByTestId('profile-menu-button').click();
await page.getByTestId('logout-item').click();
const loginPage = new LoginPage(page);
await loginPage.login();
});
await test.step('Move the user off the role', async () => {
await adminUsersPage.navigateTo();
const row = await adminUsersPage.findUserPageWithEmail(
'basic-role-test@automatisch.io'
);
await adminUsersPage.clickEditUser(row);
await adminEditUserPage.roleInput.click();
await adminEditUserPage.page.getByRole('option', { name: 'Admin' }).click();
await adminEditUserPage.updateButton.click();
await adminEditUserPage.snackbar.waitFor({
state: 'attached',
});
await adminEditUserPage.closeSnackbar();
});
await test.step('Delete the role', async () => {
await adminRolesPage.navigateTo();
const roleRow = await adminRolesPage.getRoleRowByName('Basic Test');
await adminRolesPage.clickDeleteRole(roleRow);
const deleteModal = adminRolesPage.deleteRoleModal;
await deleteModal.modal.waitFor({
state: 'attached',
});
await deleteModal.deleteButton.click();
await adminRolesPage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminRolesPage.getSnackbarData(
'snackbar-delete-role-success'
);
await expect(snackbar.variant).toBe('success');
await adminRolesPage.closeSnackbar();
await deleteModal.modal.waitFor({
state: 'detached',
});
const rowCount = await roleRow.count();
await expect(rowCount).toBe(0);
});
});

View File

@@ -0,0 +1,287 @@
const { test, expect } = require('../../fixtures/index');
/**
* NOTE: Make sure to delete all users generated between test runs,
* otherwise tests will fail since users are only *soft*-deleted
*/
test.describe('User management page', () => {
test.beforeEach(async ({ adminUsersPage }) => {
await adminUsersPage.navigateTo();
await adminUsersPage.closeSnackbar();
});
test(
'User creation and deletion process',
async ({ adminCreateUserPage, adminEditUserPage, adminUsersPage }) => {
adminCreateUserPage.seed(9000);
const user = adminCreateUserPage.generateUser();
await adminUsersPage.usersLoader.waitFor({
state: 'detached' /* Note: state: 'visible' introduces flakiness
because visibility: hidden is used as part of the state transition in
notistack, see
https://github.com/iamhosseindhv/notistack/blob/122f47057eb7ce5a1abfe923316cf8475303e99a/src/transitions/Collapse/Collapse.tsx#L110
*/
});
await test.step(
'Create a user',
async () => {
await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(user.fullName);
await adminCreateUserPage.emailInput.fill(user.email);
await adminCreateUserPage.passwordInput.fill(user.password);
await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' }
).click();
await adminCreateUserPage.createButton.click();
const snackbar = await adminUsersPage.getSnackbarData(
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
}
);
await test.step(
'Check the user exists with the expected properties',
async () => {
await adminUsersPage.findUserPageWithEmail(user.email);
const userRow = await adminUsersPage.getUserRowByEmail(user.email);
const data = await adminUsersPage.getRowData(userRow);
await expect(data.email).toBe(user.email);
await expect(data.fullName).toBe(user.fullName);
await expect(data.role).toBe('Admin');
}
);
await test.step(
'Edit user info and make sure the edit works correctly',
async () => {
await adminUsersPage.findUserPageWithEmail(user.email);
let userRow = await adminUsersPage.getUserRowByEmail(user.email);
await adminUsersPage.clickEditUser(userRow);
const newUserInfo = adminEditUserPage.generateUser();
await adminEditUserPage.fullNameInput.fill(newUserInfo.fullName);
await adminEditUserPage.updateButton.click();
const snackbar = await adminUsersPage.getSnackbarData(
'snackbar-edit-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
await adminUsersPage.findUserPageWithEmail(user.email);
userRow = await adminUsersPage.getUserRowByEmail(user.email);
const rowData = await adminUsersPage.getRowData(userRow);
await expect(rowData.fullName).toBe(newUserInfo.fullName);
}
);
await test.step(
'Delete user and check the page confirms this deletion',
async () => {
await adminUsersPage.findUserPageWithEmail(user.email);
const userRow = await adminUsersPage.getUserRowByEmail(user.email);
await adminUsersPage.clickDeleteUser(userRow);
const modal = adminUsersPage.deleteUserModal;
await modal.deleteButton.click();
const snackbar = await adminUsersPage.getSnackbarData(
'snackbar-delete-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
await expect(userRow).not.toBeVisible(false);
}
);
});
test(
'Creating a user which has been deleted',
async ({ adminCreateUserPage, adminUsersPage }) => {
adminCreateUserPage.seed(9100);
const testUser = adminCreateUserPage.generateUser();
await test.step(
'Create the test user',
async () => {
await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(testUser.fullName);
await adminCreateUserPage.emailInput.fill(testUser.email);
await adminCreateUserPage.passwordInput.fill(testUser.password);
await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' }
).click();
await adminCreateUserPage.createButton.click();
const snackbar = await adminUsersPage.getSnackbarData(
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
}
);
await test.step(
'Delete the created user',
async () => {
await adminUsersPage.findUserPageWithEmail(testUser.email);
const userRow = await adminUsersPage.getUserRowByEmail(testUser.email);
await adminUsersPage.clickDeleteUser(userRow);
const modal = adminUsersPage.deleteUserModal;
await modal.deleteButton.click();
const snackbar = await adminUsersPage.getSnackbarData(
'snackbar-delete-user-success'
);
await expect(snackbar).not.toBeNull();
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
await expect(userRow).not.toBeVisible(false);
}
);
await test.step(
'Create the user again',
async () => {
await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(testUser.fullName);
await adminCreateUserPage.emailInput.fill(testUser.email);
await adminCreateUserPage.passwordInput.fill(testUser.password);
await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' }
).click();
await adminCreateUserPage.createButton.click();
await adminUsersPage.snackbar.waitFor({
state: 'attached'
});
/*
TODO: assert snackbar behavior after deciding what should
happen here, i.e. if this should create a new user, stay the
same, un-delete the user, or something else
*/
// await adminUsersPage.getSnackbarData('snackbar-error');
await adminUsersPage.closeSnackbar();
}
);
}
);
test(
'Creating a user which already exists',
async ({ adminCreateUserPage, adminUsersPage, page }) => {
adminCreateUserPage.seed(9200);
const testUser = adminCreateUserPage.generateUser();
await test.step(
'Create the test user',
async () => {
await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(testUser.fullName);
await adminCreateUserPage.emailInput.fill(testUser.email);
await adminCreateUserPage.passwordInput.fill(testUser.password);
await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' }
).click();
await adminCreateUserPage.createButton.click();
const snackbar = await adminUsersPage.getSnackbarData(
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
}
);
await test.step(
'Create the user again',
async () => {
await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(testUser.fullName);
await adminCreateUserPage.emailInput.fill(testUser.email);
await adminCreateUserPage.passwordInput.fill(testUser.password);
const createUserPageUrl = page.url();
await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' }
).click();
await adminCreateUserPage.createButton.click();
await expect(page.url()).toBe(createUserPageUrl);
const snackbar = await adminUsersPage.getSnackbarData('snackbar-error');
await expect(snackbar.variant).toBe('error');
await adminUsersPage.closeSnackbar();
}
);
}
);
test(
'Editing a user to have the same email as another user should not be allowed',
async ({
adminCreateUserPage, adminEditUserPage, adminUsersPage, page
}) => {
adminCreateUserPage.seed(9300);
const user1 = adminCreateUserPage.generateUser();
const user2 = adminCreateUserPage.generateUser();
await test.step(
'Create the first user',
async () => {
await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(user1.fullName);
await adminCreateUserPage.emailInput.fill(user1.email);
await adminCreateUserPage.passwordInput.fill(user1.password);
await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' }
).click();
await adminCreateUserPage.createButton.click();
const snackbar = await adminUsersPage.getSnackbarData(
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
}
);
await test.step(
'Create the second user',
async () => {
await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(user2.fullName);
await adminCreateUserPage.emailInput.fill(user2.email);
await adminCreateUserPage.passwordInput.fill(user2.password);
await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' }
).click();
await adminCreateUserPage.createButton.click();
const snackbar = await adminUsersPage.getSnackbarData(
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
}
);
await test.step(
'Try editing the second user to have the email of the first user',
async () => {
await adminUsersPage.findUserPageWithEmail(user2.email);
let userRow = await adminUsersPage.getUserRowByEmail(user2.email);
await adminUsersPage.clickEditUser(userRow);
await adminEditUserPage.emailInput.fill(user1.email);
const editPageUrl = page.url();
await adminEditUserPage.updateButton.click();
const snackbar = await adminUsersPage.getSnackbarData(
'snackbar-error'
);
await expect(snackbar.variant).toBe('error');
await adminUsersPage.closeSnackbar();
await expect(page.url()).toBe(editPageUrl);
}
);
}
);
});

View File

@@ -0,0 +1,69 @@
const { test, expect } = require('../../fixtures/index');
test(
'Role permissions conform with role conditions ',
async({ adminRolesPage, adminCreateRolePage }) => {
await adminRolesPage.navigateTo();
await adminRolesPage.createRoleButton.click();
/*
example config: {
action: 'read',
subject: 'connection',
row: page.getByTestId('connection-permission-row'),
locator: row.getByTestId('read-checkbox')
}
*/
const permissionConfigs =
await adminCreateRolePage.getPermissionConfigs();
await test.step(
'Iterate over each permission config and make sure role conditions conform',
async () => {
for (let config of permissionConfigs) {
await config.locator.click();
await adminCreateRolePage.clickPermissionSettings(config.row);
const modal = adminCreateRolePage.getRoleConditionsModal(
config.subject
);
await expect(modal.modal).toBeVisible();
const conditions = await modal.getAvailableConditions();
for (let conditionAction of Object.keys(conditions)) {
if (conditionAction === config.action) {
await expect(conditions[conditionAction]).not.toBeDisabled();
} else {
await expect(conditions[conditionAction]).toBeDisabled();
}
}
await modal.close();
await config.locator.click();
}
}
);
}
);
test(
'Default role permissions conforms with role conditions',
async({ adminRolesPage, adminCreateRolePage }) => {
await adminRolesPage.navigateTo();
await adminRolesPage.createRoleButton.click();
const subjects = ['Connection', 'Execution', 'Flow'];
for (let subject of subjects) {
const row = adminCreateRolePage.getSubjectRow(subject)
const modal = adminCreateRolePage.getRoleConditionsModal(subject);
await adminCreateRolePage.clickPermissionSettings(row);
await expect(modal.modal).toBeVisible();
const availableConditions = await modal.getAvailableConditions();
const conditions = ['create', 'read', 'update', 'delete', 'publish'];
for (let condition of conditions) {
if (availableConditions[condition]) {
await expect(availableConditions[condition]).toBeDisabled();
}
}
await modal.close();
}
}
);

View File

@@ -9,7 +9,7 @@ test('Github OAuth integration', async ({ page, applicationsPage }) => {
await page.waitForURL('/apps');
}
const connectionModal = await applicationsPage.openAddConnectionModal();
expect(connectionModal.modal).toBeVisible();
await expect(connectionModal.modal).toBeVisible();
return await connectionModal.selectLink('github');
}
);
@@ -18,7 +18,7 @@ test('Github OAuth integration', async ({ page, applicationsPage }) => {
'Ensure the github connection modal is visible',
async () => {
const connectionModal = githubConnectionPage.addConnectionModal;
expect(connectionModal.modal).toBeVisible();
await expect(connectionModal.modal).toBeVisible();
return connectionModal;
}
);
@@ -35,9 +35,9 @@ test('Github OAuth integration', async ({ page, applicationsPage }) => {
);
await test.step('Ensure github popup is not a 404', async () => {
// expect(githubPopup).toBeVisible();
// await expect(githubPopup).toBeVisible();
const title = await githubPopup.title();
expect(title).not.toMatch(/^Page not found/);
await expect(title).not.toMatch(/^Page not found/);
});
/* Skip these in CI

View File

@@ -51,8 +51,8 @@ export interface IExecution {
testRun: boolean;
status: 'success' | 'failure';
executionSteps: IExecutionStep[];
updatedAt: string;
createdAt: string;
updatedAt: string | Date;
createdAt: string | Date;
}
export interface IStep {
@@ -83,8 +83,8 @@ export interface IFlow {
active: boolean;
status: 'paused' | 'published' | 'draft';
steps: IStep[];
createdAt: string;
updatedAt: string;
createdAt: string | Date;
updatedAt: string | Date;
remoteWebhookId: string;
lastInternalId: () => Promise<string>;
}

View File

@@ -74,7 +74,12 @@ export default function AddNewAppConnection(
}, []);
return (
<Dialog open={true} onClose={onClose} maxWidth="sm" fullWidth>
<Dialog
open={true}
onClose={onClose}
maxWidth="sm"
fullWidth
data-test="add-app-connection-dialog">
<DialogTitle>{formatMessage('apps.addNewAppConnection')}</DialogTitle>
<Box px={3}>

View File

@@ -6,13 +6,13 @@ import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import LoadingButton from '@mui/lab/LoadingButton';
import { useMutation } from '@apollo/client';
import { useSnackbar } from 'notistack';
import { CREATE_APP_CONFIG } from 'graphql/mutations/create-app-config';
import { UPDATE_APP_CONFIG } from 'graphql/mutations/update-app-config';
import Form from 'components/Form';
import { Switch } from './style';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
type AdminApplicationSettingsProps = {
appKey: string;
@@ -36,7 +36,7 @@ function AdminApplicationSettings(
);
const formatMessage = useFormatMessage();
const { enqueueSnackbar } = useSnackbar();
const enqueueSnackbar = useEnqueueSnackbar();
const handleSubmit = async (values: any) => {
try {
@@ -55,6 +55,9 @@ function AdminApplicationSettings(
}
enqueueSnackbar(formatMessage('adminAppsSettings.successfullySaved'), {
variant: 'success',
SnackbarProps: {
'data-test': 'snackbar-save-admin-apps-settings-success'
}
});
} catch (error) {
throw new Error('Failed while saving!');

View File

@@ -15,7 +15,12 @@ const ApolloProvider = (props: ApolloProviderProps): React.ReactElement => {
const onError = React.useCallback(
(message) => {
enqueueSnackbar(message, { variant: 'error' });
enqueueSnackbar(message, {
variant: 'error',
SnackbarProps: {
'data-test': 'snackbar-error'
}
});
},
[enqueueSnackbar]
);

View File

@@ -56,6 +56,7 @@ export default function AppBar(props: AppBarProps): React.ReactElement {
aria-label="open drawer"
onClick={drawerOpen ? onDrawerClose : onDrawerOpen}
sx={{ mr: 2 }}
data-test="drawer-menu-button"
>
{drawerOpen && matchSmallScreens ? <MenuOpenIcon /> : <MenuIcon />}
</IconButton>

View File

@@ -82,6 +82,9 @@ function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement {
enqueueSnackbar(formatMessage('connection.deletedMessage'), {
variant: 'success',
SnackbarProps: {
'data-test': 'snackbar-delete-connection-success'
}
});
} else if (action.type === 'test') {
setVerificationVisible(true);

View File

@@ -21,6 +21,7 @@ export default function ConditionalIconButton(props: any): React.ReactElement {
component={buttonProps.component}
to={buttonProps.to}
disabled={buttonProps.disabled}
data-test={buttonProps['data-test']}
>
{icon}
</IconButton>

View File

@@ -14,6 +14,7 @@ type ConfirmationDialogProps = {
cancelButtonChildren: React.ReactNode;
confirmButtionChildren: React.ReactNode;
open?: boolean;
'data-test'?: string;
}
export default function ConfirmationDialog(props: ConfirmationDialogProps) {
@@ -26,9 +27,9 @@ export default function ConfirmationDialog(props: ConfirmationDialogProps) {
confirmButtionChildren,
open = true,
} = props;
const dataTest = props['data-test'];
return (
<Dialog open={open} onClose={onClose}>
<Dialog open={open} onClose={onClose} data-test={dataTest}>
{title && (
<DialogTitle>
{title}
@@ -44,11 +45,16 @@ export default function ConfirmationDialog(props: ConfirmationDialogProps) {
<DialogActions>
{(cancelButtonChildren && onClose) && (
<Button onClick={onClose}>{cancelButtonChildren}</Button>
<Button
onClick={onClose}
data-test="confirmation-cancel-button">{cancelButtonChildren}</Button>
)}
{(confirmButtionChildren && onConfirm) && (
<Button onClick={onConfirm} color="error">
<Button
onClick={onConfirm}
color="error"
data-test="confirmation-confirm-button">
{confirmButtionChildren}
</Button>
)}

View File

@@ -5,6 +5,7 @@ import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
type ControlledCheckboxProps = {
name: string;
defaultValue?: boolean;
dataTest?: string;
} & Omit<CheckboxProps, 'defaultValue'>;
export default function ControlledCheckbox(
@@ -18,6 +19,7 @@ export default function ControlledCheckbox(
disabled = false,
onBlur,
onChange,
dataTest,
...checkboxProps
} = props;
@@ -53,6 +55,7 @@ export default function ControlledCheckbox(
onBlur?.(...args);
}}
inputRef={ref}
data-test={dataTest}
/>
);
}}

View File

@@ -31,6 +31,9 @@ export default function DeleteRoleButton(props: DeleteRoleButtonProps) {
setShowConfirmation(false);
enqueueSnackbar(formatMessage('deleteRoleButton.successfullyDeleted'), {
variant: 'success',
SnackbarProps: {
'data-test': 'snackbar-delete-role-success'
}
});
} catch (error) {
throw new Error('Failed while deleting!');
@@ -45,6 +48,7 @@ export default function DeleteRoleButton(props: DeleteRoleButtonProps) {
disabled={!allowed || disabled}
onClick={() => setShowConfirmation(true)}
size="small"
data-test="role-delete"
>
<DeleteIcon />
</IconButton>
@@ -59,6 +63,7 @@ export default function DeleteRoleButton(props: DeleteRoleButtonProps) {
onConfirm={handleConfirm}
cancelButtonChildren={formatMessage('deleteRoleButton.cancel')}
confirmButtionChildren={formatMessage('deleteRoleButton.confirm')}
data-test="delete-role-modal"
/>
</>
);

View File

@@ -29,6 +29,9 @@ export default function DeleteUserButton(props: DeleteUserButtonProps) {
setShowConfirmation(false);
enqueueSnackbar(formatMessage('deleteUserButton.successfullyDeleted'), {
variant: 'success',
SnackbarProps: {
'data-test': 'snackbar-delete-user-success'
}
});
} catch (error) {
throw new Error('Failed while deleting!');
@@ -37,7 +40,7 @@ export default function DeleteUserButton(props: DeleteUserButtonProps) {
return (
<>
<IconButton onClick={() => setShowConfirmation(true)} size="small">
<IconButton data-test="delete-button" onClick={() => setShowConfirmation(true)} size="small">
<DeleteIcon />
</IconButton>
@@ -49,6 +52,7 @@ export default function DeleteUserButton(props: DeleteUserButtonProps) {
onConfirm={handleConfirm}
cancelButtonChildren={formatMessage('deleteUserButton.cancel')}
confirmButtionChildren={formatMessage('deleteUserButton.confirm')}
data-test="delete-user-modal"
/>
</>
);

View File

@@ -39,11 +39,15 @@ function ExecutionId(props: Pick<IExecution, 'id'>) {
}
function ExecutionDate(props: Pick<IExecution, 'createdAt'>) {
const createdAt = DateTime.fromMillis(parseInt(props.createdAt, 10));
const createdAt = DateTime.fromMillis(
parseInt(props.createdAt as string, 10)
);
const relativeCreatedAt = createdAt.toRelative();
return (
<Tooltip title={createdAt.toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}>
<Tooltip
title={createdAt.toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}
>
<Typography variant="body1" gutterBottom>
{relativeCreatedAt}
</Typography>

View File

@@ -23,8 +23,10 @@ export default function ExecutionRow(
const { execution } = props;
const { flow } = execution;
const updatedAt = DateTime.fromMillis(parseInt(execution.updatedAt, 10));
const relativeUpdatedAt = updatedAt.toRelative();
const createdAt = DateTime.fromMillis(
parseInt(execution.createdAt as string, 10)
);
const relativeCreatedAt = createdAt.toRelative();
return (
<Link to={URLS.EXECUTION(execution.id)} data-test="execution-row">
@@ -41,8 +43,8 @@ export default function ExecutionRow(
</Typography>
<Typography variant="caption" noWrap>
{formatMessage('execution.updatedAt', {
datetime: relativeUpdatedAt,
{formatMessage('execution.createdAt', {
datetime: relativeCreatedAt,
})}
</Typography>
</Title>

View File

@@ -36,6 +36,9 @@ export default function ContextMenu(
enqueueSnackbar(formatMessage('flow.successfullyDuplicated'), {
variant: 'success',
SnackbarProps: {
'data-test': 'snackbar-duplicate-flow-success'
}
});
onClose();

View File

@@ -65,8 +65,8 @@ export default function FlowRow(props: FlowRowProps): React.ReactElement {
setAnchorEl(contextButtonRef.current);
};
const createdAt = DateTime.fromMillis(parseInt(flow.createdAt, 10));
const updatedAt = DateTime.fromMillis(parseInt(flow.updatedAt, 10));
const createdAt = DateTime.fromMillis(parseInt(flow.createdAt as string, 10));
const updatedAt = DateTime.fromMillis(parseInt(flow.updatedAt as string, 10));
const isUpdated = updatedAt > createdAt;
const relativeCreatedAt = createdAt.toRelative();
const relativeUpdatedAt = updatedAt.toRelative();

View File

@@ -66,10 +66,15 @@ export default function PermissionSettings(props: PermissionSettingsProps) {
};
return (
<Dialog open onClose={cancel} sx={{ display: open ? 'block' : 'none' }}>
<Dialog
open
onClose={cancel}
sx={{ display: open ? 'block' : 'none' }}
data-test={`${subject}-role-conditions-modal`}
>
<DialogTitle>{formatMessage('permissionSettings.title')}</DialogTitle>
<DialogContent>
<DialogContent data-test="role-conditions-modal-body">
<TableContainer component={Paper}>
<Table>
<TableHead>
@@ -113,6 +118,7 @@ export default function PermissionSettings(props: PermissionSettingsProps) {
{action.subjects.includes(subject) && (
<ControlledCheckbox
name={`${fieldPrefix}.${action.key}.conditions.${condition.key}`}
dataTest={`${condition.key}-${action.key.toLowerCase()}-checkbox`}
defaultValue={defaultChecked}
disabled={
getValues(

View File

@@ -62,6 +62,7 @@ const PermissionCatalogField = ({
<TableRow
key={subject.key}
sx={{ '&:last-child td': { border: 0 } }}
data-test={`${subject.key}-permission-row`}
>
<TableCell scope="row">
<Typography variant="subtitle2">{subject.label}</Typography>
@@ -74,6 +75,7 @@ const PermissionCatalogField = ({
<ControlledCheckbox
disabled={disabled}
name={`${name}.${subject.key}.${action.key}.value`}
dataTest={`${action.key.toLowerCase()}-checkbox`}
/>
)}
@@ -89,6 +91,7 @@ const PermissionCatalogField = ({
size="small"
onClick={() => setDialogName(subject.key)}
disabled={disabled}
data-test="permission-settings-button"
>
<SettingsIcon />
</IconButton>

View File

@@ -43,6 +43,9 @@ export default function ResetPasswordForm() {
enqueueSnackbar(formatMessage('resetPasswordForm.passwordUpdated'), {
variant: 'success',
SnackbarProps: {
'data-test': 'snackbar-reset-password-success'
}
});
navigate(URLS.LOGIN);

View File

@@ -49,21 +49,29 @@ export default function RoleList(): React.ReactElement {
</TableRow>
</TableHead>
<TableBody>
{loading && <ListLoader rowsNumber={3} columnsNumber={2} />}
{loading && <ListLoader
rowsNumber={3}
columnsNumber={2}
data-test="roles-list-loader" />}
{!loading &&
roles.map((role) => (
<TableRow
key={role.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
data-test="role-row"
>
<TableCell scope="row">
<Typography variant="subtitle2">{role.name}</Typography>
<Typography
variant="subtitle2"
data-test="role-name"
>{role.name}</Typography>
</TableCell>
<TableCell scope="row">
<Typography variant="subtitle2">
{role.description}
</Typography>
<Typography
variant="subtitle2"
data-test="role-description"
>{role.description}</Typography>
</TableCell>
<TableCell>
@@ -72,6 +80,7 @@ export default function RoleList(): React.ReactElement {
size="small"
component={Link}
to={URLS.ROLE(role.id)}
data-test="role-edit"
>
<EditIcon />
</IconButton>
@@ -79,6 +88,7 @@ export default function RoleList(): React.ReactElement {
<DeleteRoleButton
disabled={role.isAdmin}
roleId={role.id}
data-test="role-delete"
/>
</Stack>
</TableCell>

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