Compare commits
117 Commits
AUT-995
...
temp-branc
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6bafdf5257 | ||
![]() |
ade6282013 | ||
![]() |
bdd8da98c4 | ||
![]() |
730fdd32b1 | ||
![]() |
aa7f6694fc | ||
![]() |
8fba6704df | ||
![]() |
37d02eba02 | ||
![]() |
75a87cf070 | ||
![]() |
8acdc5853d | ||
![]() |
1aa1f441b3 | ||
![]() |
0e26032ac3 | ||
![]() |
871f25c6d9 | ||
![]() |
d8199e7ba7 | ||
![]() |
52b938eabe | ||
![]() |
94fddf3d9b | ||
![]() |
02c98c1ece | ||
![]() |
6ba77667e9 | ||
![]() |
e201a5b806 | ||
![]() |
adac68c407 | ||
![]() |
d051275e54 | ||
![]() |
2afc00364a | ||
![]() |
0a1461231b | ||
![]() |
129327f40d | ||
![]() |
58819aad94 | ||
![]() |
949a2543f5 | ||
![]() |
0f9d732667 | ||
![]() |
b7df175950 | ||
![]() |
4f7ce9874f | ||
![]() |
f63cc80383 | ||
![]() |
dd0a1328e8 | ||
![]() |
27610c002c | ||
![]() |
46ec9b5229 | ||
![]() |
778559d537 | ||
![]() |
1e64b8a903 | ||
![]() |
0e000693d6 | ||
![]() |
cf9e09ea7a | ||
![]() |
eac2f729a5 | ||
![]() |
9578cd27dd | ||
![]() |
9304ffffc9 | ||
![]() |
3fd628cb05 | ||
![]() |
fe68b70bd9 | ||
![]() |
ec2863d218 | ||
![]() |
dc56e7f883 | ||
![]() |
2f1f537e00 | ||
![]() |
b7b3a3025b | ||
![]() |
edb9526538 | ||
![]() |
62117ece06 | ||
![]() |
913a2a0ac1 | ||
![]() |
92ec3d07a3 | ||
![]() |
67ee7899fd | ||
![]() |
b0ceb3fe7e | ||
![]() |
ba6b4c6854 | ||
![]() |
61e90aed60 | ||
![]() |
45b7a399f2 | ||
![]() |
673ed25598 | ||
![]() |
14fc460174 | ||
![]() |
211b4537f9 | ||
![]() |
a683e059aa | ||
![]() |
d2d2cea567 | ||
![]() |
e5bda65a35 | ||
![]() |
6aaef9df4b | ||
![]() |
7fba4d72b0 | ||
![]() |
a6dc2ed4ba | ||
![]() |
20a40eb2f6 | ||
![]() |
ec4ac9d075 | ||
![]() |
b2c14f4226 | ||
![]() |
099dfbd0b0 | ||
![]() |
a9f5736c12 | ||
![]() |
2bd4dd3ab0 | ||
![]() |
186850c256 | ||
![]() |
9922033d33 | ||
![]() |
3c3e6e4144 | ||
![]() |
0e4ac3b7f3 | ||
![]() |
c9813e0316 | ||
![]() |
577c0cfab9 | ||
![]() |
0966f9d715 | ||
![]() |
3f5df118a0 | ||
![]() |
e05c1b26f1 | ||
![]() |
725b38c697 | ||
![]() |
402a0fdf3b | ||
![]() |
078364ffa1 | ||
![]() |
f64d5ec4fc | ||
![]() |
0666174501 | ||
![]() |
12194a50e1 | ||
![]() |
82ee592699 | ||
![]() |
1b4fb2ce6e | ||
![]() |
ebea8d12d1 | ||
![]() |
f842dd77df | ||
![]() |
a6ec7a6c99 | ||
![]() |
369c72282c | ||
![]() |
6f30c1a509 | ||
![]() |
abfd1116c7 | ||
![]() |
017854955d | ||
![]() |
1405cddea1 | ||
![]() |
00dd3164c9 | ||
![]() |
d5cbc0f611 | ||
![]() |
5d2e9ccc67 | ||
![]() |
017a881494 | ||
![]() |
52994970e6 | ||
![]() |
ebae629e5c | ||
![]() |
4d79220b0c | ||
![]() |
96fba7fbb8 | ||
![]() |
e0d610071d | ||
![]() |
ab0966c005 | ||
![]() |
751eb41e72 | ||
![]() |
f08dc25711 | ||
![]() |
737eb31776 | ||
![]() |
d6abf283bc | ||
![]() |
bac4ab5aa4 | ||
![]() |
b5839390fd | ||
![]() |
d19271dae1 | ||
![]() |
ef5a09314e | ||
![]() |
ba52e298eb | ||
![]() |
b3c3998189 | ||
![]() |
782f9b5c04 | ||
![]() |
3079d8c605 | ||
![]() |
c5202d7b3e |
3
.github/workflows/playwright.yml
vendored
3
.github/workflows/playwright.yml
vendored
@@ -71,9 +71,6 @@ jobs:
|
||||
- name: Migrate database
|
||||
working-directory: ./packages/backend
|
||||
run: yarn db:migrate
|
||||
- name: Seed user
|
||||
working-directory: ./packages/backend
|
||||
run: yarn db:seed:user &
|
||||
- name: Install certutils
|
||||
run: sudo apt install -y libnss3-tools
|
||||
- name: Install mkcert
|
||||
|
@@ -0,0 +1,64 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import defineAction from '../../../../helpers/define-action.js';
|
||||
|
||||
export default defineAction({
|
||||
name: 'Create HMAC',
|
||||
key: 'createHmac',
|
||||
description: 'Create a Hash-based Message Authentication Code (HMAC) using the specified algorithm, secret key, and message.',
|
||||
arguments: [
|
||||
{
|
||||
label: 'Algorithm',
|
||||
key: 'algorithm',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
value: 'sha256',
|
||||
description: 'Specifies the cryptographic hash function to use for HMAC generation.',
|
||||
options: [
|
||||
{ label: 'SHA-256', value: 'sha256' },
|
||||
],
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Message',
|
||||
key: 'message',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The input message to be hashed. This is the value that will be processed to generate the HMAC.',
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Secret Key',
|
||||
key: 'secretKey',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The secret key used to create the HMAC.',
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Output Encoding',
|
||||
key: 'outputEncoding',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
value: 'hex',
|
||||
description: 'Specifies the encoding format for the HMAC digest output.',
|
||||
options: [
|
||||
{ label: 'base64', value: 'base64' },
|
||||
{ label: 'base64url', value: 'base64url' },
|
||||
{ label: 'hex', value: 'hex' },
|
||||
],
|
||||
variables: true,
|
||||
},
|
||||
],
|
||||
|
||||
async run($) {
|
||||
const hash = createHmac($.step.parameters.algorithm, $.step.parameters.secretKey)
|
||||
.update($.step.parameters.message)
|
||||
.digest($.step.parameters.outputEncoding);
|
||||
|
||||
$.setActionItem({
|
||||
raw: {
|
||||
hash
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
@@ -0,0 +1,65 @@
|
||||
import crypto from 'node:crypto';
|
||||
import defineAction from '../../../../helpers/define-action.js';
|
||||
|
||||
export default defineAction({
|
||||
name: 'Create Signature',
|
||||
key: 'createSignature',
|
||||
description: 'Create a digital signature using the specified algorithm, secret key, and message.',
|
||||
arguments: [
|
||||
{
|
||||
label: 'Algorithm',
|
||||
key: 'algorithm',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
value: 'RSA-SHA256',
|
||||
description: 'Specifies the cryptographic hash function to use for HMAC generation.',
|
||||
options: [
|
||||
{ label: 'RSA-SHA256', value: 'RSA-SHA256' },
|
||||
],
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Message',
|
||||
key: 'message',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The input message to be signed.',
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Private Key',
|
||||
key: 'privateKey',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The RSA private key in PEM format used for signing.',
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Output Encoding',
|
||||
key: 'outputEncoding',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
value: 'hex',
|
||||
description: 'Specifies the encoding format for the digital signature output. This determines how the generated signature will be represented as a string.',
|
||||
options: [
|
||||
{ label: 'base64', value: 'base64' },
|
||||
{ label: 'base64url', value: 'base64url' },
|
||||
{ label: 'hex', value: 'hex' },
|
||||
],
|
||||
variables: true,
|
||||
},
|
||||
],
|
||||
|
||||
async run($) {
|
||||
const signer = crypto.createSign($.step.parameters.algorithm);
|
||||
signer.update($.step.parameters.message);
|
||||
signer.end();
|
||||
const signature = signer.sign($.step.parameters.privateKey, $.step.parameters.outputEncoding);
|
||||
|
||||
$.setActionItem({
|
||||
raw: {
|
||||
signature
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
4
packages/backend/src/apps/cryptography/actions/index.js
Normal file
4
packages/backend/src/apps/cryptography/actions/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import createHmac from './create-hmac/index.js';
|
||||
import createRsaSha256Signature from './create-rsa-sha256-signature/index.js';
|
||||
|
||||
export default [createHmac, createRsaSha256Signature];
|
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100pt" height="100pt" version="1.1" viewBox="0 0 100 100">
|
||||
<path d="m66.012 33h-3.0117v-11c0-7.1719-5.8281-13-13-13s-13 5.8281-13 13v11h-3.0117c-2.75 0-4.9883 2.2383-4.9883 4.9883v28.012c0 2.75 2.2383 4.9883 4.9883 4.9883h32.012c2.75 0 4.9883-2.2383 4.9883-4.9883v-28.012c0.011719-2.75-2.2266-4.9883-4.9766-4.9883zm-27.012-11c0-6.0703 4.9297-11 11-11s11 4.9297 11 11v11h-22zm30 44.012c0 1.6484-1.3398 2.9883-2.9883 2.9883h-32.023c-1.6484 0-2.9883-1.3398-2.9883-2.9883v-28.023c0-1.6484 1.3398-2.9883 2.9883-2.9883h32.023c1.6484 0 2.9883 1.3398 2.9883 2.9883zm-18 9.9883v14c0 0.55078-0.44922 1-1 1s-1-0.44922-1-1v-14c0-0.55078 0.44922-1 1-1s1 0.44922 1 1zm20 8c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1v-8c0-0.55078 0.44922-1 1-1s1 0.44922 1 1v7h7c0.55078 0 1 0.44922 1 1zm-32-8v8c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h7v-7c0-0.55078 0.44922-1 1-1s1 0.44922 1 1zm-14-26c0 0.55078-0.44922 1-1 1h-14c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h14c0.55078 0 1 0.44922 1 1zm0-12c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1v-8c0-0.55078 0.44922-1 1-1s1 0.44922 1 1v7h7c0.55078 0 1 0.44922 1 1zm0 24c0 0.55078-0.44922 1-1 1h-7v7c0 0.55078-0.44922 1-1 1s-1-0.44922-1-1v-8c0-0.55078 0.44922-1 1-1h8c0.55078 0 1 0.44922 1 1zm66-12c0 0.55078-0.44922 1-1 1h-14c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h14c0.55078 0 1 0.44922 1 1zm-16-12c0-0.55078 0.44922-1 1-1h7v-7c0-0.55078 0.44922-1 1-1s1 0.44922 1 1v8c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1zm10 24v8c0 0.55078-0.44922 1-1 1s-1-0.44922-1-1v-7h-7c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h8c0.55078 0 1 0.44922 1 1zm-35-17c-2.7617 0-5 2.2383-5 5 0 2.4102 1.7188 4.4297 4 4.8984v5.1016c0 0.55078 0.44922 1 1 1s1-0.44922 1-1v-5.1016c2.2812-0.46094 4-2.4805 4-4.8984 0-2.7617-2.2383-5-5-5zm0 8c-1.6484 0-3-1.3516-3-3s1.3516-3 3-3 3 1.3516 3 3-1.3516 3-3 3z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
14
packages/backend/src/apps/cryptography/index.js
Normal file
14
packages/backend/src/apps/cryptography/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import defineApp from '../../helpers/define-app.js';
|
||||
import actions from './actions/index.js';
|
||||
|
||||
export default defineApp({
|
||||
name: 'Cryptography',
|
||||
key: 'cryptography',
|
||||
iconUrl: '{BASE_URL}/apps/cryptography/assets/favicon.svg',
|
||||
authDocUrl: '{DOCS_URL}/apps/cryptography/connection',
|
||||
supportsConnections: false,
|
||||
baseUrl: '',
|
||||
apiBaseUrl: '',
|
||||
primaryColor: '001F52',
|
||||
actions,
|
||||
});
|
@@ -1,8 +1,10 @@
|
||||
import defineAction from '../../../../helpers/define-action.js';
|
||||
import formatDateTime from './transformers/format-date-time.js';
|
||||
import getCurrentTimestamp from './transformers/get-current-timestamp.js';
|
||||
|
||||
const transformers = {
|
||||
formatDateTime,
|
||||
getCurrentTimestamp,
|
||||
};
|
||||
|
||||
export default defineAction({
|
||||
@@ -16,7 +18,16 @@ export default defineAction({
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
variables: true,
|
||||
options: [{ label: 'Format Date / Time', value: 'formatDateTime' }],
|
||||
options: [
|
||||
{
|
||||
label: 'Get current timestamp',
|
||||
value: 'getCurrentTimestamp',
|
||||
},
|
||||
{
|
||||
label: 'Format Date / Time',
|
||||
value: 'formatDateTime',
|
||||
},
|
||||
],
|
||||
additionalFields: {
|
||||
type: 'query',
|
||||
name: 'getDynamicFields',
|
||||
|
@@ -0,0 +1,5 @@
|
||||
const getCurrentTimestamp = () => {
|
||||
return Date.now();
|
||||
};
|
||||
|
||||
export default getCurrentTimestamp;
|
@@ -14,6 +14,8 @@ import stringToBase64 from './transformers/string-to-base64.js';
|
||||
import encodeUri from './transformers/encode-uri.js';
|
||||
import trimWhitespace from './transformers/trim-whitespace.js';
|
||||
import useDefaultValue from './transformers/use-default-value.js';
|
||||
import parseStringifiedJson from './transformers/parse-stringified-json.js';
|
||||
import createUuid from './transformers/create-uuid.js';
|
||||
|
||||
const transformers = {
|
||||
base64ToString,
|
||||
@@ -30,6 +32,8 @@ const transformers = {
|
||||
encodeUri,
|
||||
trimWhitespace,
|
||||
useDefaultValue,
|
||||
parseStringifiedJson,
|
||||
createUuid,
|
||||
};
|
||||
|
||||
export default defineAction({
|
||||
@@ -47,19 +51,21 @@ export default defineAction({
|
||||
options: [
|
||||
{ label: 'Base64 to String', value: 'base64ToString' },
|
||||
{ label: 'Capitalize', value: 'capitalize' },
|
||||
{ label: 'Convert HTML to Markdown', value: 'htmlToMarkdown' },
|
||||
{ label: 'Convert Markdown to HTML', value: 'markdownToHtml' },
|
||||
{ label: 'Create UUID', value: 'createUuid' },
|
||||
{ label: 'Encode URI', value: 'encodeUri' },
|
||||
{
|
||||
label: 'Encode URI Component',
|
||||
value: 'encodeUriComponent',
|
||||
},
|
||||
{ label: 'Convert HTML to Markdown', value: 'htmlToMarkdown' },
|
||||
{ label: 'Convert Markdown to HTML', value: 'markdownToHtml' },
|
||||
{ label: 'Extract Email Address', value: 'extractEmailAddress' },
|
||||
{ label: 'Extract Number', value: 'extractNumber' },
|
||||
{ label: 'Lowercase', value: 'lowercase' },
|
||||
{ label: 'Parse stringified JSON', value: 'parseStringifiedJson' },
|
||||
{ label: 'Pluralize', value: 'pluralize' },
|
||||
{ label: 'Replace', value: 'replace' },
|
||||
{ label: 'String to Base64', value: 'stringToBase64' },
|
||||
{ label: 'Encode URI', value: 'encodeUri' },
|
||||
{ label: 'Trim Whitespace', value: 'trimWhitespace' },
|
||||
{ label: 'Use Default Value', value: 'useDefaultValue' },
|
||||
],
|
||||
|
@@ -0,0 +1,7 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const createUuidV4 = () => {
|
||||
return uuidv4();
|
||||
};
|
||||
|
||||
export default createUuidV4;
|
@@ -0,0 +1,7 @@
|
||||
const parseStringifiedJson = ($) => {
|
||||
const input = $.step.parameters.input;
|
||||
|
||||
return JSON.parse(input);
|
||||
};
|
||||
|
||||
export default parseStringifiedJson;
|
@@ -1,8 +1,26 @@
|
||||
const replace = ($) => {
|
||||
const input = $.step.parameters.input;
|
||||
|
||||
const find = $.step.parameters.find;
|
||||
const replace = $.step.parameters.replace;
|
||||
const useRegex = $.step.parameters.useRegex;
|
||||
|
||||
if (useRegex) {
|
||||
const ignoreCase = $.step.parameters.ignoreCase;
|
||||
|
||||
const flags = [ignoreCase && 'i', 'g'].filter(Boolean).join('');
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
$.execution.exit();
|
||||
}, 100);
|
||||
|
||||
const regex = new RegExp(find, flags);
|
||||
|
||||
const replacedValue = input.replaceAll(regex, replace);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
return replacedValue;
|
||||
}
|
||||
|
||||
return input.replaceAll(find, replace);
|
||||
};
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import listTransformOptions from './list-transform-options/index.js';
|
||||
import listReplaceRegexOptions from './list-replace-regex-options/index.js';
|
||||
|
||||
export default [listTransformOptions];
|
||||
export default [listTransformOptions, listReplaceRegexOptions];
|
||||
|
@@ -0,0 +1,23 @@
|
||||
export default {
|
||||
name: 'List replace regex options',
|
||||
key: 'listReplaceRegexOptions',
|
||||
|
||||
async run($) {
|
||||
if (!$.step.parameters.useRegex) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Ignore case',
|
||||
key: 'ignoreCase',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
description: 'Ignore case sensitivity.',
|
||||
variables: true,
|
||||
options: [
|
||||
{ label: 'Yes', value: true },
|
||||
{ label: 'No', value: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
@@ -12,6 +12,7 @@ import stringToBase64 from './text/string-to-base64.js';
|
||||
import encodeUri from './text/encode-uri.js';
|
||||
import trimWhitespace from './text/trim-whitespace.js';
|
||||
import useDefaultValue from './text/use-default-value.js';
|
||||
import parseStringifiedJson from './text/parse-stringified-json.js';
|
||||
import performMathOperation from './numbers/perform-math-operation.js';
|
||||
import randomNumber from './numbers/random-number.js';
|
||||
import formatNumber from './numbers/format-number.js';
|
||||
@@ -38,6 +39,7 @@ const options = {
|
||||
formatNumber,
|
||||
formatPhoneNumber,
|
||||
formatDateTime,
|
||||
parseStringifiedJson,
|
||||
};
|
||||
|
||||
export default {
|
||||
|
@@ -0,0 +1,12 @@
|
||||
const useDefaultValue = [
|
||||
{
|
||||
label: 'Input',
|
||||
key: 'input',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Stringified JSON you want to parse.',
|
||||
variables: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default useDefaultValue;
|
@@ -23,6 +23,33 @@ const replace = [
|
||||
description: 'Text that will replace the found text.',
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Use Regular Expression',
|
||||
key: 'useRegex',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
description: 'Use regex to search values.',
|
||||
variables: true,
|
||||
value: false,
|
||||
options: [
|
||||
{ label: 'Yes', value: true },
|
||||
{ label: 'No', value: false },
|
||||
],
|
||||
additionalFields: {
|
||||
type: 'query',
|
||||
name: 'getDynamicFields',
|
||||
arguments: [
|
||||
{
|
||||
name: 'key',
|
||||
value: 'listReplaceRegexOptions',
|
||||
},
|
||||
{
|
||||
name: 'parameters.useRegex',
|
||||
value: '{parameters.useRegex}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default replace;
|
||||
|
@@ -0,0 +1,101 @@
|
||||
import defineAction from '../../../../helpers/define-action.js';
|
||||
import listObjects from '../../dynamic-data/list-objects/index.js';
|
||||
import listFields from '../../dynamic-data/list-fields/index.js';
|
||||
|
||||
export default defineAction({
|
||||
name: 'Find partially matching record',
|
||||
key: 'findPartiallyMatchingRecord',
|
||||
description: 'Finds a record of a specified object by a field containing a value.',
|
||||
arguments: [
|
||||
{
|
||||
label: 'Object',
|
||||
key: 'object',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
variables: true,
|
||||
description: 'Pick which type of object you want to search for.',
|
||||
source: {
|
||||
type: 'query',
|
||||
name: 'getDynamicData',
|
||||
arguments: [
|
||||
{
|
||||
name: 'key',
|
||||
value: 'listObjects',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Field',
|
||||
key: 'field',
|
||||
type: 'dropdown',
|
||||
description: 'Pick which field to search by',
|
||||
required: true,
|
||||
variables: true,
|
||||
dependsOn: ['parameters.object'],
|
||||
source: {
|
||||
type: 'query',
|
||||
name: 'getDynamicData',
|
||||
arguments: [
|
||||
{
|
||||
name: 'key',
|
||||
value: 'listFields',
|
||||
},
|
||||
{
|
||||
name: 'parameters.object',
|
||||
value: '{parameters.object}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Search value to contain',
|
||||
key: 'searchValue',
|
||||
type: 'string',
|
||||
required: true,
|
||||
variables: true,
|
||||
description: 'The value to search for in the field.',
|
||||
},
|
||||
],
|
||||
|
||||
async run($) {
|
||||
const sanitizedSearchValue = $.step.parameters.searchValue.replaceAll(`'`, `\\'`);
|
||||
|
||||
// validate given object
|
||||
const objects = await listObjects.run($);
|
||||
const validObject = objects.data.find((object) => object.value === $.step.parameters.object);
|
||||
|
||||
if (!validObject) {
|
||||
throw new Error(`The "${$.step.parameters.object}" object does not exist.`);
|
||||
}
|
||||
|
||||
// validate given object field
|
||||
const fields = await listFields.run($);
|
||||
const validField = fields.data.find((field) => field.value === $.step.parameters.field);
|
||||
|
||||
if (!validField) {
|
||||
throw new Error(`The "${$.step.parameters.field}" field does not exist on the "${$.step.parameters.object}" object.`);
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
FIELDS(ALL)
|
||||
FROM
|
||||
${$.step.parameters.object}
|
||||
WHERE
|
||||
${$.step.parameters.field} LIKE '%${sanitizedSearchValue}%'
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const options = {
|
||||
params: {
|
||||
q: query,
|
||||
},
|
||||
};
|
||||
|
||||
const { data } = await $.http.get('/services/data/v61.0/query', options);
|
||||
const record = data.records[0];
|
||||
|
||||
$.setActionItem({ raw: record });
|
||||
},
|
||||
});
|
@@ -1,5 +1,6 @@
|
||||
import createAttachment from './create-attachment/index.js';
|
||||
import executeQuery from './execute-query/index.js';
|
||||
import findRecord from './find-record/index.js';
|
||||
import findPartiallyMatchingRecord from './find-partially-matching-record/index.js';
|
||||
|
||||
export default [findRecord, createAttachment, executeQuery];
|
||||
export default [findRecord, findPartiallyMatchingRecord, createAttachment, executeQuery];
|
||||
|
@@ -52,7 +52,7 @@ const appConfig = {
|
||||
isDev: appEnv === 'development',
|
||||
isTest: appEnv === 'test',
|
||||
isProd: appEnv === 'production',
|
||||
version: '0.11.0',
|
||||
version: '0.12.0',
|
||||
postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development',
|
||||
postgresSchema: process.env.POSTGRES_SCHEMA || 'public',
|
||||
postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'),
|
||||
@@ -97,8 +97,12 @@ const appConfig = {
|
||||
disableNotificationsPage: process.env.DISABLE_NOTIFICATIONS_PAGE === 'true',
|
||||
disableFavicon: process.env.DISABLE_FAVICON === 'true',
|
||||
additionalDrawerLink: process.env.ADDITIONAL_DRAWER_LINK,
|
||||
additionalDrawerLinkIcon: process.env.ADDITIONAL_DRAWER_LINK_ICON,
|
||||
additionalDrawerLinkText: process.env.ADDITIONAL_DRAWER_LINK_TEXT,
|
||||
disableSeedUser: process.env.DISABLE_SEED_USER === 'true',
|
||||
httpProxy: process.env.http_proxy,
|
||||
httpsProxy: process.env.https_proxy,
|
||||
noProxy: process.env.no_proxy,
|
||||
};
|
||||
|
||||
if (!appConfig.encryptionKey) {
|
||||
|
@@ -0,0 +1,10 @@
|
||||
import User from '../../../../../models/user.js';
|
||||
|
||||
export default async (request, response) => {
|
||||
const id = request.params.userId;
|
||||
|
||||
const user = await User.query().findById(id).throwIfNotFound();
|
||||
await user.softRemove();
|
||||
|
||||
response.status(204).end();
|
||||
};
|
@@ -0,0 +1,43 @@
|
||||
import { describe, it, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import Crypto from 'crypto';
|
||||
import app from '../../../../../app.js';
|
||||
import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id';
|
||||
import { createUser } from '../../../../../../test/factories/user';
|
||||
import { createRole } from '../../../../../../test/factories/role';
|
||||
|
||||
describe('DELETE /api/v1/admin/users/:userId', () => {
|
||||
let currentUser, currentUserRole, anotherUser, token;
|
||||
|
||||
beforeEach(async () => {
|
||||
currentUserRole = await createRole({ key: 'admin' });
|
||||
currentUser = await createUser({ roleId: currentUserRole.id });
|
||||
|
||||
anotherUser = await createUser();
|
||||
|
||||
token = await createAuthTokenByUserId(currentUser.id);
|
||||
});
|
||||
|
||||
it('should soft delete user and respond with no content', async () => {
|
||||
await request(app)
|
||||
.delete(`/api/v1/admin/users/${anotherUser.id}`)
|
||||
.set('Authorization', token)
|
||||
.expect(204);
|
||||
});
|
||||
|
||||
it('should return not found response for not existing user UUID', async () => {
|
||||
const notExistingUserUUID = Crypto.randomUUID();
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/v1/admin/users/${notExistingUserUUID}`)
|
||||
.set('Authorization', token)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return bad request response for invalid UUID', async () => {
|
||||
await request(app)
|
||||
.delete('/api/v1/admin/users/invalidUserUUID')
|
||||
.set('Authorization', token)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
@@ -7,6 +7,7 @@ export default async (request, response) => {
|
||||
disableNotificationsPage: appConfig.disableNotificationsPage,
|
||||
disableFavicon: appConfig.disableFavicon,
|
||||
additionalDrawerLink: appConfig.additionalDrawerLink,
|
||||
additionalDrawerLinkIcon: appConfig.additionalDrawerLinkIcon,
|
||||
additionalDrawerLinkText: appConfig.additionalDrawerLinkText,
|
||||
};
|
||||
|
||||
|
@@ -4,6 +4,7 @@ import { createConfig } from '../../../../../test/factories/config.js';
|
||||
import app from '../../../../app.js';
|
||||
import configMock from '../../../../../test/mocks/rest/api/v1/automatisch/config.js';
|
||||
import * as license from '../../../../helpers/license.ee.js';
|
||||
import appConfig from '../../../../config/app.js';
|
||||
|
||||
describe('GET /api/v1/automatisch/config', () => {
|
||||
it('should return Automatisch config', async () => {
|
||||
@@ -48,4 +49,18 @@ describe('GET /api/v1/automatisch/config', () => {
|
||||
|
||||
expect(response.body).toEqual(expectedPayload);
|
||||
});
|
||||
|
||||
it('should return additional environment variables', async () => {
|
||||
vi.spyOn(appConfig, 'disableNotificationsPage', 'get').mockReturnValue(true);
|
||||
vi.spyOn(appConfig, 'disableFavicon', 'get').mockReturnValue(true);
|
||||
vi.spyOn(appConfig, 'additionalDrawerLink', 'get').mockReturnValue('link');
|
||||
vi.spyOn(appConfig, 'additionalDrawerLinkIcon', 'get').mockReturnValue('icon');
|
||||
vi.spyOn(appConfig, 'additionalDrawerLinkText', 'get').mockReturnValue('text');
|
||||
|
||||
expect(appConfig.disableNotificationsPage).toEqual(true);
|
||||
expect(appConfig.disableFavicon).toEqual(true);
|
||||
expect(appConfig.additionalDrawerLink).toEqual('link');
|
||||
expect(appConfig.additionalDrawerLinkIcon).toEqual('icon');
|
||||
expect(appConfig.additionalDrawerLinkText).toEqual('text');
|
||||
});
|
||||
});
|
||||
|
@@ -7,6 +7,7 @@ export default async (request, response) => {
|
||||
isCloud: appConfig.isCloud,
|
||||
isMation: appConfig.isMation,
|
||||
isEnterprise: await hasValidLicense(),
|
||||
docsUrl: appConfig.docsUrl,
|
||||
};
|
||||
|
||||
renderObject(response, info);
|
||||
|
@@ -10,6 +10,7 @@ describe('GET /api/v1/automatisch/info', () => {
|
||||
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false);
|
||||
vi.spyOn(appConfig, 'isMation', 'get').mockReturnValue(false);
|
||||
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
|
||||
vi.spyOn(appConfig, 'docsUrl', 'get').mockReturnValue('https://automatisch.io/docs');
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/v1/automatisch/info')
|
||||
|
@@ -10,7 +10,7 @@ describe('GET /api/v1/automatisch/version', () => {
|
||||
|
||||
const expectedPayload = {
|
||||
data: {
|
||||
version: '0.11.0',
|
||||
version: '0.12.0',
|
||||
},
|
||||
meta: {
|
||||
count: 1,
|
||||
|
@@ -0,0 +1,21 @@
|
||||
import User from '../../../../models/user.js';
|
||||
|
||||
export default async (request, response) => {
|
||||
const { token, password } = request.body;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Invitation token is required!');
|
||||
}
|
||||
|
||||
const user = await User.query()
|
||||
.findOne({ invitation_token: token })
|
||||
.throwIfNotFound();
|
||||
|
||||
if (!user.isInvitationTokenValid()) {
|
||||
return response.status(422).end();
|
||||
}
|
||||
|
||||
await user.acceptInvitation(password);
|
||||
|
||||
response.status(204).end();
|
||||
};
|
@@ -0,0 +1,13 @@
|
||||
import User from '../../../../models/user.js';
|
||||
|
||||
export default async (request, response) => {
|
||||
const { email } = request.body;
|
||||
|
||||
const user = await User.query()
|
||||
.findOne({ email: email.toLowerCase() })
|
||||
.throwIfNotFound();
|
||||
|
||||
await user.sendResetPasswordEmail();
|
||||
|
||||
response.status(204).end();
|
||||
};
|
@@ -0,0 +1,30 @@
|
||||
import { describe, it, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import app from '../../../../app.js';
|
||||
import { createUser } from '../../../../../test/factories/user';
|
||||
|
||||
describe('POST /api/v1/users/forgot-password', () => {
|
||||
let currentUser;
|
||||
|
||||
beforeEach(async () => {
|
||||
currentUser = await createUser();
|
||||
});
|
||||
|
||||
it('should respond with no content', async () => {
|
||||
await request(app)
|
||||
.post('/api/v1/users/forgot-password')
|
||||
.send({
|
||||
email: currentUser.email,
|
||||
})
|
||||
.expect(204);
|
||||
});
|
||||
|
||||
it('should return not found response for not existing user UUID', async () => {
|
||||
await request(app)
|
||||
.post('/api/v1/users/forgot-password')
|
||||
.send({
|
||||
email: 'nonexisting@automatisch.io',
|
||||
})
|
||||
.expect(404);
|
||||
});
|
||||
});
|
@@ -0,0 +1,23 @@
|
||||
import User from '../../../../models/user.js';
|
||||
import { renderError } from '../../../../helpers/renderer.js';
|
||||
|
||||
export default async (request, response) => {
|
||||
const { token, password } = request.body;
|
||||
|
||||
const user = await User.query()
|
||||
.findOne({
|
||||
reset_password_token: token,
|
||||
})
|
||||
.throwIfNotFound();
|
||||
|
||||
if (!user.isResetPasswordTokenValid()) {
|
||||
return renderError(response, [{ general: [invalidTokenErrorMessage] }]);
|
||||
}
|
||||
|
||||
await user.resetPassword(password);
|
||||
|
||||
response.status(204).end();
|
||||
};
|
||||
|
||||
const invalidTokenErrorMessage =
|
||||
'Reset password link is not valid or expired. Try generating a new link.';
|
@@ -0,0 +1,49 @@
|
||||
import { describe, it, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { DateTime } from 'luxon';
|
||||
import app from '../../../../app.js';
|
||||
import { createUser } from '../../../../../test/factories/user';
|
||||
|
||||
describe('POST /api/v1/users/reset-password', () => {
|
||||
let currentUser;
|
||||
|
||||
beforeEach(async () => {
|
||||
currentUser = await createUser({
|
||||
resetPasswordToken: 'sampleResetPasswordToken',
|
||||
resetPasswordTokenSentAt: DateTime.now().toISO(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should respond with no content', async () => {
|
||||
await request(app)
|
||||
.post('/api/v1/users/reset-password')
|
||||
.send({
|
||||
token: currentUser.resetPasswordToken,
|
||||
password: 'newPassword',
|
||||
})
|
||||
.expect(204);
|
||||
});
|
||||
|
||||
it('should return not found response for not existing user', async () => {
|
||||
await request(app)
|
||||
.post('/api/v1/users/reset-password')
|
||||
.send({
|
||||
token: 'nonExistingResetPasswordToken',
|
||||
})
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return unprocessable entity for existing user with expired reset password token', async () => {
|
||||
const user = await createUser({
|
||||
resetPasswordToken: 'anotherResetPasswordToken',
|
||||
resetPasswordTokenSentAt: DateTime.now().minus({ days: 2 }).toISO(),
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post('/api/v1/users/reset-password')
|
||||
.send({
|
||||
token: user.resetPasswordToken,
|
||||
})
|
||||
.expect(422);
|
||||
});
|
||||
});
|
@@ -5,9 +5,11 @@ export async function up(knex) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex) {
|
||||
return knex.schema.alterTable('steps', (table) => {
|
||||
table.string('key').notNullable().alter();
|
||||
table.string('app_key').notNullable().alter();
|
||||
});
|
||||
export async function down() {
|
||||
// We can't use down migration here since there are null values which needs to be set!
|
||||
// We don't want to set those values by default key and app key since it will mislead users.
|
||||
// return knex.schema.alterTable('steps', (table) => {
|
||||
// table.string('key').notNullable().alter();
|
||||
// table.string('app_key').notNullable().alter();
|
||||
// });
|
||||
}
|
||||
|
@@ -0,0 +1,11 @@
|
||||
export async function up(knex) {
|
||||
return knex.schema.table('users', (table) => {
|
||||
table.string('status').defaultTo('active');
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex) {
|
||||
return knex.schema.table('users', (table) => {
|
||||
table.dropColumn('status');
|
||||
});
|
||||
}
|
@@ -0,0 +1,13 @@
|
||||
export async function up(knex) {
|
||||
return knex.schema.table('users', (table) => {
|
||||
table.string('invitation_token');
|
||||
table.timestamp('invitation_token_sent_at');
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex) {
|
||||
return knex.schema.table('users', (table) => {
|
||||
table.dropColumn('invitation_token');
|
||||
table.dropColumn('invitation_token_sent_at');
|
||||
});
|
||||
}
|
@@ -10,15 +10,11 @@ import deleteCurrentUser from './mutations/delete-current-user.ee.js';
|
||||
import deleteFlow from './mutations/delete-flow.js';
|
||||
import deleteRole from './mutations/delete-role.ee.js';
|
||||
import deleteStep from './mutations/delete-step.js';
|
||||
import deleteUser from './mutations/delete-user.ee.js';
|
||||
import duplicateFlow from './mutations/duplicate-flow.js';
|
||||
import executeFlow from './mutations/execute-flow.js';
|
||||
import forgotPassword from './mutations/forgot-password.ee.js';
|
||||
import generateAuthUrl from './mutations/generate-auth-url.js';
|
||||
import login from './mutations/login.js';
|
||||
import registerUser from './mutations/register-user.ee.js';
|
||||
import resetConnection from './mutations/reset-connection.js';
|
||||
import resetPassword from './mutations/reset-password.ee.js';
|
||||
import updateAppAuthClient from './mutations/update-app-auth-client.ee.js';
|
||||
import updateAppConfig from './mutations/update-app-config.ee.js';
|
||||
import updateConfig from './mutations/update-config.ee.js';
|
||||
@@ -46,15 +42,11 @@ const mutationResolvers = {
|
||||
deleteFlow,
|
||||
deleteRole,
|
||||
deleteStep,
|
||||
deleteUser,
|
||||
duplicateFlow,
|
||||
executeFlow,
|
||||
forgotPassword,
|
||||
generateAuthUrl,
|
||||
login,
|
||||
registerUser,
|
||||
resetConnection,
|
||||
resetPassword,
|
||||
updateAppAuthClient,
|
||||
updateAppConfig,
|
||||
updateConfig,
|
||||
|
@@ -1,10 +1,16 @@
|
||||
import appConfig from '../../config/app.js';
|
||||
import User from '../../models/user.js';
|
||||
import Role from '../../models/role.js';
|
||||
import emailQueue from '../../queues/email.js';
|
||||
import {
|
||||
REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
||||
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
||||
} from '../../helpers/remove-job-configuration.js';
|
||||
|
||||
const createUser = async (_parent, params, context) => {
|
||||
context.currentUser.can('create', 'User');
|
||||
|
||||
const { fullName, email, password } = params.input;
|
||||
const { fullName, email } = params.input;
|
||||
|
||||
const existingUser = await User.query().findOne({
|
||||
email: email.toLowerCase(),
|
||||
@@ -17,7 +23,7 @@ const createUser = async (_parent, params, context) => {
|
||||
const userPayload = {
|
||||
fullName,
|
||||
email,
|
||||
password,
|
||||
status: 'invited',
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -32,7 +38,29 @@ const createUser = async (_parent, params, context) => {
|
||||
|
||||
const user = await User.query().insert(userPayload);
|
||||
|
||||
return user;
|
||||
await user.generateInvitationToken();
|
||||
|
||||
const jobName = `Invitation Email - ${user.id}`;
|
||||
const acceptInvitationUrl = `${appConfig.webAppUrl}/accept-invitation?token=${user.invitationToken}`;
|
||||
|
||||
const jobPayload = {
|
||||
email: user.email,
|
||||
subject: 'You are invited!',
|
||||
template: 'invitation-instructions',
|
||||
params: {
|
||||
fullName: user.fullName,
|
||||
acceptInvitationUrl,
|
||||
},
|
||||
};
|
||||
|
||||
const jobOptions = {
|
||||
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
||||
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
||||
};
|
||||
|
||||
await emailQueue.add(jobName, jobPayload, jobOptions);
|
||||
|
||||
return { user, acceptInvitationUrl };
|
||||
};
|
||||
|
||||
export default createUser;
|
||||
|
@@ -1,24 +0,0 @@
|
||||
import { Duration } from 'luxon';
|
||||
import User from '../../models/user.js';
|
||||
import deleteUserQueue from '../../queues/delete-user.ee.js';
|
||||
|
||||
const deleteUser = async (_parent, params, context) => {
|
||||
context.currentUser.can('delete', 'User');
|
||||
|
||||
const id = params.input.id;
|
||||
|
||||
await User.query().deleteById(id);
|
||||
|
||||
const jobName = `Delete user - ${id}`;
|
||||
const jobPayload = { id };
|
||||
const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis();
|
||||
const jobOptions = {
|
||||
delay: millisecondsFor30Days,
|
||||
};
|
||||
|
||||
await deleteUserQueue.add(jobName, jobPayload, jobOptions);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default deleteUser;
|
@@ -1,43 +0,0 @@
|
||||
import appConfig from '../../config/app.js';
|
||||
import User from '../../models/user.js';
|
||||
import emailQueue from '../../queues/email.js';
|
||||
import {
|
||||
REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
||||
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
||||
} from '../../helpers/remove-job-configuration.js';
|
||||
|
||||
const forgotPassword = async (_parent, params) => {
|
||||
const { email } = params.input;
|
||||
|
||||
const user = await User.query().findOne({ email: email.toLowerCase() });
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Email address not found!');
|
||||
}
|
||||
|
||||
await user.generateResetPasswordToken();
|
||||
|
||||
const jobName = `Reset Password Email - ${user.id}`;
|
||||
|
||||
const jobPayload = {
|
||||
email: user.email,
|
||||
subject: 'Reset Password',
|
||||
template: 'reset-password-instructions',
|
||||
params: {
|
||||
token: user.resetPasswordToken,
|
||||
webAppUrl: appConfig.webAppUrl,
|
||||
fullName: user.fullName,
|
||||
},
|
||||
};
|
||||
|
||||
const jobOptions = {
|
||||
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
||||
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
||||
};
|
||||
|
||||
await emailQueue.add(jobName, jobPayload, jobOptions);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default forgotPassword;
|
@@ -1,17 +0,0 @@
|
||||
import User from '../../models/user.js';
|
||||
import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id.js';
|
||||
|
||||
const login = async (_parent, params) => {
|
||||
const user = await User.query().findOne({
|
||||
email: params.input.email.toLowerCase(),
|
||||
});
|
||||
|
||||
if (user && (await user.login(params.input.password))) {
|
||||
const token = await createAuthTokenByUserId(user.id);
|
||||
return { token, user };
|
||||
}
|
||||
|
||||
throw new Error('User could not be found.');
|
||||
};
|
||||
|
||||
export default login;
|
@@ -1,23 +0,0 @@
|
||||
import User from '../../models/user.js';
|
||||
|
||||
const resetPassword = async (_parent, params) => {
|
||||
const { token, password } = params.input;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Reset password token is required!');
|
||||
}
|
||||
|
||||
const user = await User.query().findOne({ reset_password_token: token });
|
||||
|
||||
if (!user || !user.isResetPasswordTokenValid()) {
|
||||
throw new Error(
|
||||
'Reset password link is not valid or expired. Try generating a new link.'
|
||||
);
|
||||
}
|
||||
|
||||
await user.resetPassword(password);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default resetPassword;
|
@@ -8,21 +8,17 @@ type Mutation {
|
||||
createFlow(input: CreateFlowInput): Flow
|
||||
createRole(input: CreateRoleInput): Role
|
||||
createStep(input: CreateStepInput): Step
|
||||
createUser(input: CreateUserInput): User
|
||||
createUser(input: CreateUserInput): UserWithAcceptInvitationUrl
|
||||
deleteConnection(input: DeleteConnectionInput): Boolean
|
||||
deleteCurrentUser: Boolean
|
||||
deleteFlow(input: DeleteFlowInput): Boolean
|
||||
deleteRole(input: DeleteRoleInput): Boolean
|
||||
deleteStep(input: DeleteStepInput): Step
|
||||
deleteUser(input: DeleteUserInput): Boolean
|
||||
duplicateFlow(input: DuplicateFlowInput): Flow
|
||||
executeFlow(input: ExecuteFlowInput): executeFlowType
|
||||
forgotPassword(input: ForgotPasswordInput): Boolean
|
||||
generateAuthUrl(input: GenerateAuthUrlInput): AuthLink
|
||||
login(input: LoginInput): Auth
|
||||
registerUser(input: RegisterUserInput): User
|
||||
resetConnection(input: ResetConnectionInput): Connection
|
||||
resetPassword(input: ResetPasswordInput): Boolean
|
||||
updateAppAuthClient(input: UpdateAppAuthClientInput): AppAuthClient
|
||||
updateAppConfig(input: UpdateAppConfigInput): AppConfig
|
||||
updateConfig(input: JSONObject): JSONObject
|
||||
@@ -154,11 +150,6 @@ enum ArgumentEnumType {
|
||||
string
|
||||
}
|
||||
|
||||
type Auth {
|
||||
user: User
|
||||
token: String
|
||||
}
|
||||
|
||||
type AuthenticationStep {
|
||||
type: String
|
||||
name: String
|
||||
@@ -375,7 +366,6 @@ input DeleteStepInput {
|
||||
input CreateUserInput {
|
||||
fullName: String!
|
||||
email: String!
|
||||
password: String!
|
||||
role: UserRoleInput!
|
||||
}
|
||||
|
||||
@@ -390,10 +380,6 @@ input UpdateUserInput {
|
||||
role: UserRoleInput
|
||||
}
|
||||
|
||||
input DeleteUserInput {
|
||||
id: String!
|
||||
}
|
||||
|
||||
input RegisterUserInput {
|
||||
fullName: String!
|
||||
email: String!
|
||||
@@ -406,20 +392,6 @@ input UpdateCurrentUserInput {
|
||||
fullName: String
|
||||
}
|
||||
|
||||
input ForgotPasswordInput {
|
||||
email: String!
|
||||
}
|
||||
|
||||
input ResetPasswordInput {
|
||||
token: String!
|
||||
password: String!
|
||||
}
|
||||
|
||||
input LoginInput {
|
||||
email: String!
|
||||
password: String!
|
||||
}
|
||||
|
||||
input PermissionInput {
|
||||
action: String!
|
||||
subject: String!
|
||||
@@ -520,6 +492,11 @@ type User {
|
||||
updatedAt: String
|
||||
}
|
||||
|
||||
type UserWithAcceptInvitationUrl {
|
||||
user: User
|
||||
acceptInvitationUrl: String
|
||||
}
|
||||
|
||||
type Role {
|
||||
id: String
|
||||
name: String
|
||||
|
@@ -53,10 +53,7 @@ const isAuthenticatedRule = rule()(isAuthenticated);
|
||||
export const authenticationRules = {
|
||||
Mutation: {
|
||||
'*': isAuthenticatedRule,
|
||||
forgotPassword: allow,
|
||||
login: allow,
|
||||
registerUser: allow,
|
||||
resetPassword: allow,
|
||||
},
|
||||
};
|
||||
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import axios from 'axios';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import appConfig from '../config/app.js';
|
||||
|
||||
const config = axios.defaults;
|
||||
const httpProxyUrl = process.env.http_proxy;
|
||||
const httpsProxyUrl = process.env.https_proxy;
|
||||
const httpProxyUrl = appConfig.httpProxy;
|
||||
const httpsProxyUrl = appConfig.httpsProxy;
|
||||
const supportsProxy = httpProxyUrl || httpsProxyUrl;
|
||||
const noProxyEnv = process.env.no_proxy;
|
||||
const noProxyEnv = appConfig.noProxy;
|
||||
const noProxyHosts = noProxyEnv ? noProxyEnv.split(',').map(host => host.trim()) : [];
|
||||
|
||||
if (supportsProxy) {
|
||||
@@ -29,15 +30,43 @@ function shouldSkipProxy(hostname) {
|
||||
});
|
||||
};
|
||||
|
||||
axiosWithProxyInstance.interceptors.request.use(function skipProxyIfInNoProxy(requestConfig) {
|
||||
const hostname = new URL(requestConfig.url).hostname;
|
||||
/**
|
||||
* The interceptors are executed in the reverse order they are added.
|
||||
*/
|
||||
axiosWithProxyInstance.interceptors.request.use(
|
||||
function skipProxyIfInNoProxy(requestConfig) {
|
||||
const hostname = new URL(requestConfig.baseURL).hostname;
|
||||
|
||||
if (supportsProxy && shouldSkipProxy(hostname)) {
|
||||
requestConfig.httpAgent = undefined;
|
||||
requestConfig.httpsAgent = undefined;
|
||||
}
|
||||
if (supportsProxy && shouldSkipProxy(hostname)) {
|
||||
requestConfig.httpAgent = undefined;
|
||||
requestConfig.httpsAgent = undefined;
|
||||
}
|
||||
|
||||
return requestConfig;
|
||||
});
|
||||
return requestConfig;
|
||||
},
|
||||
undefined,
|
||||
{ synchronous: true }
|
||||
);
|
||||
|
||||
axiosWithProxyInstance.interceptors.request.use(
|
||||
function removeBaseUrlForAbsoluteUrls(requestConfig) {
|
||||
/**
|
||||
* If the URL is an absolute URL, we remove its origin out of the URL
|
||||
* and set it as baseURL. This lets us streamlines the requests made by Automatisch
|
||||
* and requests made by app integrations.
|
||||
*/
|
||||
try {
|
||||
const url = new URL(requestConfig.url);
|
||||
requestConfig.baseURL = url.origin;
|
||||
requestConfig.url = url.pathname + url.search;
|
||||
|
||||
return requestConfig;
|
||||
} catch {
|
||||
return requestConfig;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
{ synchronous: true}
|
||||
);
|
||||
|
||||
export default axiosWithProxyInstance;
|
||||
|
119
packages/backend/src/helpers/axios-with-proxy.test.js
Normal file
119
packages/backend/src/helpers/axios-with-proxy.test.js
Normal file
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('Axios with proxy', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('should have two interceptors by default', async () => {
|
||||
const axios = (await import('./axios-with-proxy.js')).default;
|
||||
const requestInterceptors = axios.interceptors.request.handlers;
|
||||
|
||||
expect(requestInterceptors.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should have default interceptors in a certain order', async () => {
|
||||
const axios = (await import('./axios-with-proxy.js')).default;
|
||||
|
||||
const requestInterceptors = axios.interceptors.request.handlers;
|
||||
const firstRequestInterceptor = requestInterceptors[0];
|
||||
const secondRequestInterceptor = requestInterceptors[1];
|
||||
|
||||
expect(firstRequestInterceptor.fulfilled.name).toBe('skipProxyIfInNoProxy');
|
||||
expect(secondRequestInterceptor.fulfilled.name).toBe('removeBaseUrlForAbsoluteUrls');
|
||||
});
|
||||
|
||||
describe('skipProxyIfInNoProxy', () => {
|
||||
let appConfig, axios;
|
||||
beforeEach(async() => {
|
||||
appConfig = (await import('../config/app.js')).default;
|
||||
|
||||
vi.spyOn(appConfig, 'httpProxy', 'get').mockReturnValue('http://proxy.automatisch.io');
|
||||
vi.spyOn(appConfig, 'httpsProxy', 'get').mockReturnValue('http://proxy.automatisch.io');
|
||||
vi.spyOn(appConfig, 'noProxy', 'get').mockReturnValue('name.tld,automatisch.io');
|
||||
|
||||
axios = (await import('./axios-with-proxy.js')).default;
|
||||
});
|
||||
|
||||
it('should skip proxy for hosts in no_proxy environment variable', async () => {
|
||||
const skipProxyIfInNoProxy = axios.interceptors.request.handlers[0].fulfilled;
|
||||
|
||||
const mockRequestConfig = {
|
||||
...axios.defaults,
|
||||
baseURL: 'https://automatisch.io'
|
||||
};
|
||||
|
||||
const interceptedRequestConfig = skipProxyIfInNoProxy(mockRequestConfig);
|
||||
|
||||
expect(interceptedRequestConfig.httpAgent).toBeUndefined();
|
||||
expect(interceptedRequestConfig.httpsAgent).toBeUndefined();
|
||||
expect(interceptedRequestConfig.proxy).toBe(false);
|
||||
});
|
||||
|
||||
it('should not skip proxy for hosts not in no_proxy environment variable', async () => {
|
||||
const skipProxyIfInNoProxy = axios.interceptors.request.handlers[0].fulfilled;
|
||||
|
||||
const mockRequestConfig = {
|
||||
...axios.defaults,
|
||||
// beware the intentional typo!
|
||||
baseURL: 'https://automatish.io'
|
||||
};
|
||||
|
||||
const interceptedRequestConfig = skipProxyIfInNoProxy(mockRequestConfig);
|
||||
|
||||
expect(interceptedRequestConfig.httpAgent).toBeDefined();
|
||||
expect(interceptedRequestConfig.httpsAgent).toBeDefined();
|
||||
expect(interceptedRequestConfig.proxy).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeBaseUrlForAbsoluteUrls', () => {
|
||||
let axios;
|
||||
beforeEach(async() => {
|
||||
axios = (await import('./axios-with-proxy.js')).default;
|
||||
});
|
||||
|
||||
it('should trim the baseUrl from absolute urls', async () => {
|
||||
const removeBaseUrlForAbsoluteUrls = axios.interceptors.request.handlers[1].fulfilled;
|
||||
|
||||
const mockRequestConfig = {
|
||||
...axios.defaults,
|
||||
url: 'https://automatisch.io/path'
|
||||
};
|
||||
|
||||
const interceptedRequestConfig = removeBaseUrlForAbsoluteUrls(mockRequestConfig);
|
||||
|
||||
expect(interceptedRequestConfig.baseURL).toBe('https://automatisch.io');
|
||||
expect(interceptedRequestConfig.url).toBe('/path');
|
||||
});
|
||||
|
||||
it('should not mutate separate baseURL and urls', async () => {
|
||||
const removeBaseUrlForAbsoluteUrls = axios.interceptors.request.handlers[1].fulfilled;
|
||||
|
||||
const mockRequestConfig = {
|
||||
...axios.defaults,
|
||||
baseURL: 'https://automatisch.io',
|
||||
url: '/path?query=1'
|
||||
};
|
||||
|
||||
const interceptedRequestConfig = removeBaseUrlForAbsoluteUrls(mockRequestConfig);
|
||||
|
||||
expect(interceptedRequestConfig.baseURL).toBe('https://automatisch.io');
|
||||
expect(interceptedRequestConfig.url).toBe('/path?query=1');
|
||||
});
|
||||
|
||||
it('should not strip querystring from url', async () => {
|
||||
const removeBaseUrlForAbsoluteUrls = axios.interceptors.request.handlers[1].fulfilled;
|
||||
|
||||
const mockRequestConfig = {
|
||||
...axios.defaults,
|
||||
url: 'https://automatisch.io/path?query=1'
|
||||
};
|
||||
|
||||
const interceptedRequestConfig = removeBaseUrlForAbsoluteUrls(mockRequestConfig);
|
||||
|
||||
expect(interceptedRequestConfig.baseURL).toBe('https://automatisch.io');
|
||||
expect(interceptedRequestConfig.url).toBe('/path?query=1');
|
||||
});
|
||||
});
|
||||
});
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'url';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const compileEmail = (emailPath, replacements = {}) => {
|
||||
const filePath = path.join(__dirname, `../views/emails/${emailPath}.ee.hbs`);
|
||||
const filePath = path.join(__dirname, `../views/emails/${emailPath}.hbs`);
|
||||
const source = fs.readFileSync(filePath, 'utf-8').toString();
|
||||
const template = handlebars.compile(source);
|
||||
return template(replacements);
|
||||
|
@@ -98,9 +98,9 @@ const globalVariable = async (options) => {
|
||||
});
|
||||
|
||||
return {
|
||||
key: datastore.key,
|
||||
value: datastore.value,
|
||||
[datastore.key]: datastore.value,
|
||||
key: key,
|
||||
value: datastore?.value ?? null,
|
||||
[key]: datastore?.value ?? null,
|
||||
};
|
||||
},
|
||||
set: async ({ key, value }) => {
|
||||
|
@@ -1,41 +1,38 @@
|
||||
import { URL } from 'node:url';
|
||||
import HttpError from '../../errors/http.js';
|
||||
import axios from '../axios-with-proxy.js';
|
||||
|
||||
const removeBaseUrlForAbsoluteUrls = (requestConfig) => {
|
||||
try {
|
||||
const url = new URL(requestConfig.url);
|
||||
requestConfig.baseURL = url.origin;
|
||||
requestConfig.url = url.pathname + url.search;
|
||||
|
||||
return requestConfig;
|
||||
} catch {
|
||||
return requestConfig;
|
||||
}
|
||||
};
|
||||
// Mutates the `toInstance` by copying the request interceptors from `fromInstance`
|
||||
const copyRequestInterceptors = (fromInstance, toInstance) => {
|
||||
// Copy request interceptors
|
||||
fromInstance.interceptors.request.forEach(interceptor => {
|
||||
toInstance.interceptors.request.use(
|
||||
interceptor.fulfilled,
|
||||
interceptor.rejected,
|
||||
{
|
||||
synchronous: interceptor.synchronous,
|
||||
runWhen: interceptor.runWhen
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default function createHttpClient({ $, baseURL, beforeRequest = [] }) {
|
||||
const instance = axios.create({
|
||||
baseURL,
|
||||
});
|
||||
|
||||
// 1. apply the beforeRequest functions from the app
|
||||
instance.interceptors.request.use((requestConfig) => {
|
||||
const newRequestConfig = removeBaseUrlForAbsoluteUrls(requestConfig);
|
||||
|
||||
const result = beforeRequest.reduce((newConfig, beforeRequestFunc) => {
|
||||
return beforeRequestFunc($, newConfig);
|
||||
}, newRequestConfig);
|
||||
}, requestConfig);
|
||||
|
||||
/**
|
||||
* axios seems to want InternalAxiosRequestConfig returned not AxioRequestConfig
|
||||
* anymore even though requests do require AxiosRequestConfig.
|
||||
*
|
||||
* Since both interfaces are very similar (InternalAxiosRequestConfig
|
||||
* extends AxiosRequestConfig), we can utilize an assertion below
|
||||
**/
|
||||
return result;
|
||||
});
|
||||
|
||||
// 2. inherit the request inceptors from the parent instance
|
||||
copyRequestInterceptors(axios, instance);
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import { DateTime } from 'luxon';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import appConfig from '../config/app.js';
|
||||
@@ -21,6 +21,13 @@ import Subscription from './subscription.ee.js';
|
||||
import UsageData from './usage-data.ee.js';
|
||||
import Billing from '../helpers/billing/index.ee.js';
|
||||
|
||||
import deleteUserQueue from '../queues/delete-user.ee.js';
|
||||
import emailQueue from '../queues/email.js';
|
||||
import {
|
||||
REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
||||
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
||||
} from '../helpers/remove-job-configuration.js';
|
||||
|
||||
class User extends Base {
|
||||
static tableName = 'users';
|
||||
|
||||
@@ -33,8 +40,21 @@ class User extends Base {
|
||||
fullName: { type: 'string', minLength: 1 },
|
||||
email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 },
|
||||
password: { type: 'string' },
|
||||
resetPasswordToken: { type: 'string' },
|
||||
resetPasswordTokenSentAt: { type: 'string' },
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['active', 'invited'],
|
||||
default: 'active',
|
||||
},
|
||||
resetPasswordToken: { type: ['string', 'null'] },
|
||||
resetPasswordTokenSentAt: {
|
||||
type: ['string', 'null'],
|
||||
format: 'date-time',
|
||||
},
|
||||
invitationToken: { type: ['string', 'null'] },
|
||||
invitationTokenSentAt: {
|
||||
type: ['string', 'null'],
|
||||
format: 'date-time',
|
||||
},
|
||||
trialExpiryDate: { type: 'string' },
|
||||
roleId: { type: 'string', format: 'uuid' },
|
||||
deletedAt: { type: 'string' },
|
||||
@@ -202,6 +222,13 @@ class User extends Base {
|
||||
await this.$query().patch({ resetPasswordToken, resetPasswordTokenSentAt });
|
||||
}
|
||||
|
||||
async generateInvitationToken() {
|
||||
const invitationToken = crypto.randomBytes(64).toString('hex');
|
||||
const invitationTokenSentAt = new Date().toISOString();
|
||||
|
||||
await this.$query().patch({ invitationToken, invitationTokenSentAt });
|
||||
}
|
||||
|
||||
async resetPassword(password) {
|
||||
return await this.$query().patch({
|
||||
resetPasswordToken: null,
|
||||
@@ -210,7 +237,53 @@ class User extends Base {
|
||||
});
|
||||
}
|
||||
|
||||
async isResetPasswordTokenValid() {
|
||||
async acceptInvitation(password) {
|
||||
return await this.$query().patch({
|
||||
invitationToken: null,
|
||||
invitationTokenSentAt: null,
|
||||
status: 'active',
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
async softRemove() {
|
||||
await this.$query().delete();
|
||||
|
||||
const jobName = `Delete user - ${this.id}`;
|
||||
const jobPayload = { id: this.id };
|
||||
const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis();
|
||||
const jobOptions = {
|
||||
delay: millisecondsFor30Days,
|
||||
};
|
||||
|
||||
await deleteUserQueue.add(jobName, jobPayload, jobOptions);
|
||||
}
|
||||
|
||||
async sendResetPasswordEmail() {
|
||||
await this.generateResetPasswordToken();
|
||||
|
||||
const jobName = `Reset Password Email - ${this.id}`;
|
||||
|
||||
const jobPayload = {
|
||||
email: this.email,
|
||||
subject: 'Reset Password',
|
||||
template: 'reset-password-instructions.ee',
|
||||
params: {
|
||||
token: this.resetPasswordToken,
|
||||
webAppUrl: appConfig.webAppUrl,
|
||||
fullName: this.fullName,
|
||||
},
|
||||
};
|
||||
|
||||
const jobOptions = {
|
||||
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
||||
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
||||
};
|
||||
|
||||
await emailQueue.add(jobName, jobPayload, jobOptions);
|
||||
}
|
||||
|
||||
isResetPasswordTokenValid() {
|
||||
if (!this.resetPasswordTokenSentAt) {
|
||||
return false;
|
||||
}
|
||||
@@ -222,6 +295,18 @@ class User extends Base {
|
||||
return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds;
|
||||
}
|
||||
|
||||
isInvitationTokenValid() {
|
||||
if (!this.invitationTokenSentAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sentAt = new Date(this.invitationTokenSentAt);
|
||||
const now = new Date();
|
||||
const seventyTwoHoursInMilliseconds = 1000 * 60 * 60 * 72;
|
||||
|
||||
return now.getTime() - sentAt.getTime() < seventyTwoHoursInMilliseconds;
|
||||
}
|
||||
|
||||
async generateHash() {
|
||||
if (this.password) {
|
||||
this.password = await bcrypt.hash(this.password, 10);
|
||||
@@ -381,7 +466,7 @@ class User extends Base {
|
||||
email,
|
||||
password,
|
||||
fullName,
|
||||
roleId: adminRole.id
|
||||
roleId: adminRole.id,
|
||||
});
|
||||
|
||||
await Config.markInstallationCompleted();
|
||||
|
@@ -4,6 +4,7 @@ import { authenticateUser } from '../../../../helpers/authentication.js';
|
||||
import { authorizeAdmin } from '../../../../helpers/authorization.js';
|
||||
import getUsersAction from '../../../../controllers/api/v1/admin/users/get-users.ee.js';
|
||||
import getUserAction from '../../../../controllers/api/v1/admin/users/get-user.ee.js';
|
||||
import deleteUserAction from '../../../../controllers/api/v1/admin/users/delete-user.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -16,4 +17,11 @@ router.get(
|
||||
asyncHandler(getUserAction)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:userId',
|
||||
authenticateUser,
|
||||
authorizeAdmin,
|
||||
asyncHandler(deleteUserAction)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
@@ -9,6 +9,9 @@ import getAppsAction from '../../../controllers/api/v1/users/get-apps.js';
|
||||
import getInvoicesAction from '../../../controllers/api/v1/users/get-invoices.ee.js';
|
||||
import getSubscriptionAction from '../../../controllers/api/v1/users/get-subscription.ee.js';
|
||||
import getPlanAndUsageAction from '../../../controllers/api/v1/users/get-plan-and-usage.ee.js';
|
||||
import acceptInvitationAction from '../../../controllers/api/v1/users/accept-invitation.js';
|
||||
import forgotPasswordAction from '../../../controllers/api/v1/users/forgot-password.js';
|
||||
import resetPasswordAction from '../../../controllers/api/v1/users/reset-password.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -49,4 +52,9 @@ router.get(
|
||||
asyncHandler(getPlanAndUsageAction)
|
||||
);
|
||||
|
||||
router.post('/invitation', asyncHandler(acceptInvitationAction));
|
||||
router.post('/forgot-password', asyncHandler(forgotPasswordAction));
|
||||
|
||||
router.post('/reset-password', asyncHandler(resetPasswordAction));
|
||||
|
||||
export default router;
|
||||
|
@@ -8,6 +8,7 @@ const userSerializer = (user) => {
|
||||
email: user.email,
|
||||
createdAt: user.createdAt.getTime(),
|
||||
updatedAt: user.updatedAt.getTime(),
|
||||
status: user.status,
|
||||
fullName: user.fullName,
|
||||
};
|
||||
|
||||
|
@@ -35,6 +35,7 @@ describe('userSerializer', () => {
|
||||
email: user.email,
|
||||
fullName: user.fullName,
|
||||
id: user.id,
|
||||
status: user.status,
|
||||
updatedAt: user.updatedAt.getTime(),
|
||||
};
|
||||
|
||||
|
@@ -0,0 +1,23 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Invitation instructions</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>
|
||||
Hello {{ fullName }},
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You have been invited to join our platform. To accept the invitation, click the link below.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ acceptInvitationUrl }}">Accept invitation</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you did not expect this invitation, you can ignore this email.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
@@ -9,7 +9,7 @@
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Someone has requested a link to change your password, and you can do this through the link below.
|
||||
Someone has requested a link to change your password, and you can do this through the link below within 72 hours.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
@@ -40,6 +40,7 @@ export const worker = new Worker(
|
||||
await user.$relatedQuery('usageData').withSoftDeleted().hardDelete();
|
||||
}
|
||||
|
||||
await user.$relatedQuery('accessTokens').withSoftDeleted().hardDelete();
|
||||
await user.$query().withSoftDeleted().hardDelete();
|
||||
},
|
||||
{ connection: redisConfig }
|
||||
|
@@ -21,7 +21,7 @@ export const worker = new Worker(
|
||||
async (job) => {
|
||||
const { email, subject, template, params } = job.data;
|
||||
|
||||
if (isCloudSandbox && !isAutomatischEmail(email)) {
|
||||
if (isCloudSandbox() && !isAutomatischEmail(email)) {
|
||||
logger.info(
|
||||
'Only Automatisch emails are allowed for non-production environments!'
|
||||
);
|
||||
|
@@ -14,6 +14,7 @@ const getUserMock = (currentUser, role) => {
|
||||
name: role.name,
|
||||
updatedAt: role.updatedAt.getTime(),
|
||||
},
|
||||
status: currentUser.status,
|
||||
trialExpiryDate: currentUser.trialExpiryDate.toISOString(),
|
||||
updatedAt: currentUser.updatedAt.getTime(),
|
||||
},
|
||||
|
@@ -18,6 +18,7 @@ const getUsersMock = async (users, roles) => {
|
||||
updatedAt: role.updatedAt.getTime(),
|
||||
}
|
||||
: null,
|
||||
status: user.status,
|
||||
trialExpiryDate: user.trialExpiryDate.toISOString(),
|
||||
updatedAt: user.updatedAt.getTime(),
|
||||
};
|
||||
|
@@ -4,6 +4,7 @@ const infoMock = () => {
|
||||
isCloud: false,
|
||||
isMation: false,
|
||||
isEnterprise: true,
|
||||
docsUrl: 'https://automatisch.io/docs',
|
||||
},
|
||||
meta: {
|
||||
count: 1,
|
||||
|
@@ -23,6 +23,7 @@ const getCurrentUserMock = (currentUser, role, permissions) => {
|
||||
name: role.name,
|
||||
updatedAt: role.updatedAt.getTime(),
|
||||
},
|
||||
status: currentUser.status,
|
||||
trialExpiryDate: currentUser.trialExpiryDate.toISOString(),
|
||||
updatedAt: currentUser.updatedAt.getTime(),
|
||||
},
|
||||
|
@@ -59,6 +59,15 @@ export default defineConfig({
|
||||
{ text: 'Connection', link: '/apps/carbone/connection' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Cryptography',
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'Actions', link: '/apps/cryptography/actions' },
|
||||
{ text: 'Connection', link: '/apps/cryptography/connection' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Datastore',
|
||||
collapsible: true,
|
||||
|
14
packages/docs/pages/apps/cryptography/actions.md
Normal file
14
packages/docs/pages/apps/cryptography/actions.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
favicon: /favicons/cryptography.svg
|
||||
items:
|
||||
- name: Create HMAC
|
||||
desc: Create a Hash-based Message Authentication Code (HMAC) using the specified algorithm, secret key, and message.
|
||||
- name: Create Signature
|
||||
desc: Create a digital signature using the specified algorithm, secret key, and message.
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import CustomListing from '../../components/CustomListing.vue'
|
||||
</script>
|
||||
|
||||
<CustomListing />
|
3
packages/docs/pages/apps/cryptography/connection.md
Normal file
3
packages/docs/pages/apps/cryptography/connection.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Cryptography
|
||||
|
||||
Cryptography is a built-in app shipped with Automatisch, allowing you to perform cryptographic operations without needing to connect to any external services.
|
@@ -5,6 +5,8 @@ items:
|
||||
desc: Creates an attachment of a specified object by given parent ID.
|
||||
- name: Find record
|
||||
desc: Finds a record of a specified object by a field and value.
|
||||
- name: Find partially matching record
|
||||
desc: Finds a record of a specified object by a field containing a value.
|
||||
- name: Execute query
|
||||
desc: Executes a SOQL query in Salesforce.
|
||||
---
|
||||
|
@@ -6,16 +6,12 @@ We use `lerna` with `yarn workspaces` to manage the mono repository. We have the
|
||||
.
|
||||
├── packages
|
||||
│ ├── backend
|
||||
│ ├── cli
|
||||
│ ├── docs
|
||||
│ ├── e2e-tests
|
||||
│ ├── types
|
||||
│ └── web
|
||||
```
|
||||
|
||||
- `backend` - The backend package contains the backend application and all integrations.
|
||||
- `cli` - The cli package contains the CLI application of Automatisch.
|
||||
- `docs` - The docs package contains the documentation website.
|
||||
- `e2e-tests` - The e2e-tests package contains the end-to-end tests for the internal usage.
|
||||
- `types` - The types package contains the shared types for both the backend and web packages.
|
||||
- `web` - The web package contains the frontend application of Automatisch.
|
||||
|
3
packages/docs/pages/public/favicons/cryptography.svg
Normal file
3
packages/docs/pages/public/favicons/cryptography.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100pt" height="100pt" version="1.1" viewBox="0 0 100 100">
|
||||
<path d="m66.012 33h-3.0117v-11c0-7.1719-5.8281-13-13-13s-13 5.8281-13 13v11h-3.0117c-2.75 0-4.9883 2.2383-4.9883 4.9883v28.012c0 2.75 2.2383 4.9883 4.9883 4.9883h32.012c2.75 0 4.9883-2.2383 4.9883-4.9883v-28.012c0.011719-2.75-2.2266-4.9883-4.9766-4.9883zm-27.012-11c0-6.0703 4.9297-11 11-11s11 4.9297 11 11v11h-22zm30 44.012c0 1.6484-1.3398 2.9883-2.9883 2.9883h-32.023c-1.6484 0-2.9883-1.3398-2.9883-2.9883v-28.023c0-1.6484 1.3398-2.9883 2.9883-2.9883h32.023c1.6484 0 2.9883 1.3398 2.9883 2.9883zm-18 9.9883v14c0 0.55078-0.44922 1-1 1s-1-0.44922-1-1v-14c0-0.55078 0.44922-1 1-1s1 0.44922 1 1zm20 8c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1v-8c0-0.55078 0.44922-1 1-1s1 0.44922 1 1v7h7c0.55078 0 1 0.44922 1 1zm-32-8v8c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h7v-7c0-0.55078 0.44922-1 1-1s1 0.44922 1 1zm-14-26c0 0.55078-0.44922 1-1 1h-14c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h14c0.55078 0 1 0.44922 1 1zm0-12c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1v-8c0-0.55078 0.44922-1 1-1s1 0.44922 1 1v7h7c0.55078 0 1 0.44922 1 1zm0 24c0 0.55078-0.44922 1-1 1h-7v7c0 0.55078-0.44922 1-1 1s-1-0.44922-1-1v-8c0-0.55078 0.44922-1 1-1h8c0.55078 0 1 0.44922 1 1zm66-12c0 0.55078-0.44922 1-1 1h-14c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h14c0.55078 0 1 0.44922 1 1zm-16-12c0-0.55078 0.44922-1 1-1h7v-7c0-0.55078 0.44922-1 1-1s1 0.44922 1 1v8c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1zm10 24v8c0 0.55078-0.44922 1-1 1s-1-0.44922-1-1v-7h-7c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h8c0.55078 0 1 0.44922 1 1zm-35-17c-2.7617 0-5 2.2383-5 5 0 2.4102 1.7188 4.4297 4 4.8984v5.1016c0 0.55078 0.44922 1 1 1s1-0.44922 1-1v-5.1016c2.2812-0.46094 4-2.4805 4-4.8984 0-2.7617-2.2383-5-5-5zm0 8c-1.6484 0-3-1.3516-3-3s1.3516-3 3-3 3 1.3516 3 3-1.3516 3-3 3z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
5
packages/e2e-tests/.env-example
Normal file
5
packages/e2e-tests/.env-example
Normal file
@@ -0,0 +1,5 @@
|
||||
POSTGRES_DB=automatisch
|
||||
POSTGRES_USER=automatisch_user
|
||||
POSTGRES_PASSWORD=automatisch_password
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_HOST=localhost
|
6
packages/e2e-tests/.eslintignore
Normal file
6
packages/e2e-tests/.eslintignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
build
|
||||
|
||||
.eslintrc.js
|
||||
|
||||
playwright-report/*
|
25
packages/e2e-tests/.eslintrc.json
Normal file
25
packages/e2e-tests/.eslintrc.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true,
|
||||
"es6": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"prettier"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"semi": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"indent": [
|
||||
"error",
|
||||
2
|
||||
]
|
||||
}
|
||||
}
|
@@ -44,6 +44,14 @@ and it should install the associated browsers for the test running. For more inf
|
||||
|
||||
We recommend using [Playwright Test for VSCode](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) maintained by Microsoft. This lets you run playwright tests from within the code editor, giving you access to additional tools, such as easily running subsets of tests.
|
||||
|
||||
[Global setup and teardown](https://playwright.dev/docs/test-global-setup-teardown) are part of the tests.
|
||||
|
||||
By running `yarn test` setup and teardown actions will take place.
|
||||
|
||||
If you need to setup Admin account (if you didn't seed the DB with the admin account or have clean DB) you should run `auth.setup.js` file.
|
||||
|
||||
If you want to clean the database (drop tables) and perform required migrations run `global.teardown.js`.
|
||||
|
||||
# Test failures
|
||||
|
||||
If there are failing tests in the test suite, this can be caused by a myriad of reasons, but one of the best places to start is either running the test in a headed browser, looking at the associated trace file for the failed test, or checking out the output of a failed GitHub Action.
|
||||
|
46
packages/e2e-tests/fixtures/accept-invitation-page.js
Normal file
46
packages/e2e-tests/fixtures/accept-invitation-page.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const { expect } = require('@playwright/test');
|
||||
const { BasePage } = require('./base-page');
|
||||
|
||||
export class AcceptInvitation extends BasePage {
|
||||
path = '/accept-invitation';
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
constructor(page) {
|
||||
super(page);
|
||||
|
||||
this.page = page;
|
||||
this.passwordTextField = this.page.getByTestId('password-text-field');
|
||||
this.passwordConfirmationTextField = this.page.getByTestId('confirm-password-text-field');
|
||||
this.submitButton = this.page.getByTestId('submit-button');
|
||||
this.pageTitle = this.page.getByTestId('accept-invitation-form-title');
|
||||
this.formErrorMessage = this.page.getByTestId('accept-invitation-form-error');
|
||||
}
|
||||
|
||||
async open(token) {
|
||||
return await this.page.goto(`${this.path}?token=${token}`);
|
||||
}
|
||||
|
||||
async acceptInvitation(
|
||||
password
|
||||
) {
|
||||
await this.passwordTextField.fill(password);
|
||||
await this.passwordConfirmationTextField.fill(password);
|
||||
|
||||
await this.submitButton.click();
|
||||
}
|
||||
|
||||
async fillPasswordField(password) {
|
||||
await this.passwordTextField.fill(password);
|
||||
await this.passwordConfirmationTextField.fill(password);
|
||||
}
|
||||
|
||||
async excpectSubmitButtonToBeDisabled() {
|
||||
await expect(this.submitButton).toBeDisabled();
|
||||
}
|
||||
|
||||
async expectAlertToBeVisible() {
|
||||
await expect(this.formErrorMessage).toBeVisible();
|
||||
}
|
||||
}
|
75
packages/e2e-tests/fixtures/admin-setup-page.js
Normal file
75
packages/e2e-tests/fixtures/admin-setup-page.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { BasePage } from "./base-page";
|
||||
const { faker } = require('@faker-js/faker');
|
||||
const { expect } = require('@playwright/test');
|
||||
|
||||
export class AdminSetupPage extends BasePage {
|
||||
path = '/installation';
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
constructor(page) {
|
||||
super(page);
|
||||
|
||||
this.fullNameTextField = this.page.getByTestId('fullName-text-field');
|
||||
this.emailTextField = this.page.getByTestId('email-text-field');
|
||||
this.passwordTextField = this.page.getByTestId('password-text-field');
|
||||
this.repeatPasswordTextField = this.page.getByTestId('repeat-password-text-field');
|
||||
this.createAdminButton = this.page.getByTestId('signUp-button');
|
||||
this.invalidFields = this.page.locator('p.Mui-error');
|
||||
this.successAlert = this.page.getByTestId('success-alert');
|
||||
}
|
||||
|
||||
async open() {
|
||||
return await this.page.goto(this.path);
|
||||
}
|
||||
|
||||
async fillValidUserData() {
|
||||
await this.fullNameTextField.fill(process.env.LOGIN_EMAIL);
|
||||
await this.emailTextField.fill(process.env.LOGIN_EMAIL);
|
||||
await this.passwordTextField.fill(process.env.LOGIN_PASSWORD);
|
||||
await this.repeatPasswordTextField.fill(process.env.LOGIN_PASSWORD);
|
||||
}
|
||||
|
||||
async fillInvalidUserData() {
|
||||
await this.fullNameTextField.fill('');
|
||||
await this.emailTextField.fill('abcde');
|
||||
await this.passwordTextField.fill('');
|
||||
await this.repeatPasswordTextField.fill('a');
|
||||
}
|
||||
|
||||
async fillNotMatchingPasswordUserData() {
|
||||
const testUser = this.generateUser();
|
||||
await this.fullNameTextField.fill(testUser.fullName);
|
||||
await this.emailTextField.fill(testUser.email);
|
||||
await this.passwordTextField.fill(testUser.password);
|
||||
await this.repeatPasswordTextField.fill(testUser.wronglyRepeatedPassword);
|
||||
}
|
||||
|
||||
async submitAdminForm() {
|
||||
await this.createAdminButton.click();
|
||||
}
|
||||
|
||||
async expectInvalidFields(errorCount) {
|
||||
await expect(await this.invalidFields.all()).toHaveLength(errorCount);
|
||||
}
|
||||
|
||||
async expectSuccessAlertToBeVisible() {
|
||||
await expect(await this.successAlert).toBeVisible();
|
||||
}
|
||||
|
||||
async expectSuccessMessageToContainLoginLink() {
|
||||
await expect(await this.successAlert.locator('a')).toHaveAttribute('href', '/login');
|
||||
}
|
||||
|
||||
generateUser() {
|
||||
faker.seed(Math.ceil(Math.random() * Number.MAX_SAFE_INTEGER));
|
||||
|
||||
return {
|
||||
fullName: faker.person.fullName(),
|
||||
email: faker.internet.email(),
|
||||
password: faker.internet.password(),
|
||||
wronglyRepeatedPassword: faker.internet.password()
|
||||
};
|
||||
}
|
||||
};
|
@@ -11,10 +11,11 @@ export class AdminCreateUserPage extends AuthenticatedPage {
|
||||
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');
|
||||
this.pageTitle = page.getByTestId('create-user-title');
|
||||
this.invitationEmailInfoAlert = page.getByTestId('invitation-email-info-alert');
|
||||
this.acceptInvitationLink = page.getByTestId('invitation-email-info-alert').getByRole('link');
|
||||
}
|
||||
|
||||
seed(seed) {
|
||||
@@ -25,7 +26,6 @@ export class AdminCreateUserPage extends AuthenticatedPage {
|
||||
return {
|
||||
fullName: faker.person.fullName(),
|
||||
email: faker.internet.email().toLowerCase(),
|
||||
password: faker.internet.password(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -14,6 +14,6 @@ export class DeleteUserModal {
|
||||
async close () {
|
||||
await this.page.click('body', {
|
||||
position: { x: 10, y: 10 }
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
const { AdminCreateRolePage } = require('./create-role-page')
|
||||
const { AdminCreateRolePage } = require('./create-role-page');
|
||||
|
||||
export class AdminEditRolePage extends AdminCreateRolePage {
|
||||
constructor (page) {
|
||||
|
@@ -23,6 +23,7 @@ export class AdminEditUserPage extends AuthenticatedPage {
|
||||
*/
|
||||
async waitForLoad(fullName) {
|
||||
return await this.page.waitForFunction((fullName) => {
|
||||
// eslint-disable-next-line no-undef
|
||||
const el = document.querySelector("[data-test='full-name-input']");
|
||||
return el && el.value === fullName;
|
||||
}, fullName);
|
||||
|
@@ -25,5 +25,5 @@ export const adminFixtures = {
|
||||
adminCreateRolePage: async ({ page}, use) => {
|
||||
await use(new AdminCreateRolePage(page));
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -87,6 +87,7 @@ export class AdminUsersPage extends AuthenticatedPage {
|
||||
await this.firstPageButton.click();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (await this.usersLoader.isVisible()) {
|
||||
await this.usersLoader.waitFor({
|
||||
@@ -108,6 +109,7 @@ export class AdminUsersPage extends AuthenticatedPage {
|
||||
|
||||
async getTotalRows() {
|
||||
return await this.page.evaluate(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
const node = document.querySelector('[data-total-count]');
|
||||
if (node) {
|
||||
const count = Number(node.dataset.totalCount);
|
||||
@@ -121,6 +123,7 @@ export class AdminUsersPage extends AuthenticatedPage {
|
||||
|
||||
async getRowsPerPage() {
|
||||
return await this.page.evaluate(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
const node = document.querySelector('[data-rows-per-page]');
|
||||
if (node) {
|
||||
const count = Number(node.dataset.rowsPerPage);
|
||||
|
@@ -25,7 +25,7 @@ export class ApplicationsModal extends BasePage {
|
||||
if (this.applications[link] === undefined) {
|
||||
throw {
|
||||
message: `Unknown link "${link}" passed to ApplicationsModal.selectLink`
|
||||
}
|
||||
};
|
||||
}
|
||||
await this.searchInput.fill(link);
|
||||
await this.appListItem.first().click();
|
||||
|
@@ -1,4 +1,3 @@
|
||||
const path = require('node:path');
|
||||
const { ApplicationsModal } = require('./applications-modal');
|
||||
const { AuthenticatedPage } = require('./authenticated-page');
|
||||
|
||||
|
@@ -1,10 +1,11 @@
|
||||
const { BasePage } = require('../../base-page');
|
||||
const { AddGithubConnectionModal } = require('./add-github-connection-modal');
|
||||
const { expect } = require('@playwright/test');
|
||||
|
||||
export class GithubPage extends BasePage {
|
||||
|
||||
constructor (page) {
|
||||
super(page)
|
||||
super(page);
|
||||
this.addConnectionButton = page.getByTestId('add-connection-button');
|
||||
this.connectionsTab = page.getByTestId('connections-tab');
|
||||
this.flowsTab = page.getByTestId('flows-tab');
|
||||
@@ -38,7 +39,7 @@ export class GithubPage extends BasePage {
|
||||
await this.flowsTab.click();
|
||||
await expect(this.flowsTab).toBeVisible();
|
||||
}
|
||||
return await this.flowRows.count() > 0
|
||||
return await this.flowRows.count() > 0;
|
||||
}
|
||||
|
||||
async hasConnections () {
|
||||
|
@@ -1,4 +1,5 @@
|
||||
const { BasePage } = require('../../base-page');
|
||||
const { expect } = require('@playwright/test');
|
||||
|
||||
export class GithubPopup extends BasePage {
|
||||
|
||||
@@ -11,7 +12,7 @@ export class GithubPopup extends BasePage {
|
||||
}
|
||||
|
||||
getPathname () {
|
||||
const url = this.page.url()
|
||||
const url = this.page.url();
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch (e) {
|
||||
@@ -34,17 +35,17 @@ export class GithubPopup extends BasePage {
|
||||
loginInput.click();
|
||||
await loginInput.fill(process.env.GITHUB_USERNAME);
|
||||
const passwordInput = this.page.getByLabel('Password');
|
||||
passwordInput.click()
|
||||
passwordInput.click();
|
||||
await passwordInput.fill(process.env.GITHUB_PASSWORD);
|
||||
await this.page.getByRole('button', { name: 'Sign in' }).click();
|
||||
// await this.page.waitForTimeout(2000);
|
||||
if (this.page.isClosed()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// await this.page.waitForLoadState('networkidle', 30000);
|
||||
this.page.waitForEvent('load');
|
||||
if (this.page.isClosed()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
await this.page.waitForURL(function (url) {
|
||||
const u = new URL(url);
|
||||
@@ -55,7 +56,7 @@ export class GithubPopup extends BasePage {
|
||||
}
|
||||
|
||||
async handleAuthorize () {
|
||||
if (this.page.isClosed()) { return }
|
||||
if (this.page.isClosed()) { return; }
|
||||
const authorizeButton = this.page.getByRole(
|
||||
'button',
|
||||
{ name: 'Authorize' }
|
||||
@@ -69,7 +70,7 @@ export class GithubPopup extends BasePage {
|
||||
) && (
|
||||
u.searchParams.get('client_id') === null
|
||||
);
|
||||
})
|
||||
});
|
||||
const passwordInput = this.page.getByLabel('Password');
|
||||
if (await passwordInput.isVisible()) {
|
||||
await passwordInput.fill(process.env.GITHUB_PASSWORD);
|
||||
@@ -87,6 +88,6 @@ export class GithubPopup extends BasePage {
|
||||
};
|
||||
}
|
||||
}
|
||||
await this.page.waitForEvent('close')
|
||||
await this.page.waitForEvent('close');
|
||||
}
|
||||
}
|
@@ -1,7 +1,4 @@
|
||||
const path = require('node:path');
|
||||
const { expect } = require('@playwright/test');
|
||||
const { BasePage } = require('./base-page');
|
||||
const { LoginPage } = require('./login-page');
|
||||
|
||||
export class AuthenticatedPage extends BasePage {
|
||||
/**
|
||||
|
@@ -1,4 +1,3 @@
|
||||
const path = require('node:path');
|
||||
const { AuthenticatedPage } = require('./authenticated-page');
|
||||
|
||||
export class ConnectionsPage extends AuthenticatedPage {
|
||||
|
@@ -1,4 +1,3 @@
|
||||
const path = require('node:path');
|
||||
const { AuthenticatedPage } = require('./authenticated-page');
|
||||
|
||||
export class ExecutionsPage extends AuthenticatedPage {
|
||||
|
@@ -1,4 +1,3 @@
|
||||
const path = require('node:path');
|
||||
const { AuthenticatedPage } = require('./authenticated-page');
|
||||
|
||||
export class FlowEditorPage extends AuthenticatedPage {
|
||||
|
@@ -5,7 +5,10 @@ const { ExecutionsPage } = require('./executions-page');
|
||||
const { FlowEditorPage } = require('./flow-editor-page');
|
||||
const { UserInterfacePage } = require('./user-interface-page');
|
||||
const { LoginPage } = require('./login-page');
|
||||
const { AcceptInvitation } = require('./accept-invitation-page');
|
||||
const { adminFixtures } = require('./admin');
|
||||
const { AdminSetupPage } = require('./admin-setup-page');
|
||||
const { AdminCreateUserPage } = require('./admin/create-user-page');
|
||||
|
||||
exports.test = test.extend({
|
||||
page: async ({ page }, use) => {
|
||||
@@ -46,6 +49,21 @@ exports.publicTest = test.extend({
|
||||
|
||||
await use(loginPage);
|
||||
},
|
||||
|
||||
acceptInvitationPage: async ({ page }, use) => {
|
||||
const acceptInvitationPage = new AcceptInvitation(page);
|
||||
await use(acceptInvitationPage);
|
||||
},
|
||||
|
||||
adminSetupPage: async ({ page }, use) => {
|
||||
const adminSetupPage = new AdminSetupPage(page);
|
||||
await use(adminSetupPage);
|
||||
},
|
||||
|
||||
adminCreateUserPage: async ({page}, use) => {
|
||||
const adminCreateUserPage = new AdminCreateUserPage(page);
|
||||
await use(adminCreateUserPage);
|
||||
}
|
||||
});
|
||||
|
||||
expect.extend({
|
||||
|
11
packages/e2e-tests/fixtures/postgres-client-config.js
Normal file
11
packages/e2e-tests/fixtures/postgres-client-config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const { Client } = require('pg');
|
||||
|
||||
const client = new Client({
|
||||
host: process.env.POSTGRES_HOST,
|
||||
user: process.env.POSTGRES_USERNAME,
|
||||
port: process.env.POSTGRES_PORT,
|
||||
password: process.env.POSTGRES_PASSWORD,
|
||||
database: process.env.POSTGRES_DATABASE
|
||||
});
|
||||
|
||||
exports.client = client;
|
@@ -1,4 +1,3 @@
|
||||
const path = require('node:path');
|
||||
const { AuthenticatedPage } = require('./authenticated-page');
|
||||
|
||||
export class UserInterfacePage extends AuthenticatedPage {
|
||||
|
25
packages/e2e-tests/knexfile.js
Normal file
25
packages/e2e-tests/knexfile.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const fileExtension = 'js';
|
||||
|
||||
const knexConfig = {
|
||||
client: 'pg',
|
||||
connection: {
|
||||
host: process.env.POSTGRES_HOST,
|
||||
user: process.env.POSTGRES_USERNAME,
|
||||
port: process.env.POSTGRES_PORT,
|
||||
password: process.env.POSTGRES_PASSWORD,
|
||||
database: process.env.POSTGRES_DATABASE
|
||||
},
|
||||
searchPath: ['public'],
|
||||
pool: { min: 0, max: 20 },
|
||||
migrations: {
|
||||
directory: '../../packages/backend/src/db/migrations/',
|
||||
extension: fileExtension,
|
||||
loadExtensions: [`.${fileExtension}`],
|
||||
},
|
||||
seeds: {
|
||||
directory: '../../packages/backend/src/db/seeds',
|
||||
},
|
||||
...(process.env.APP_ENV === 'test' ? knexSnakeCaseMappers() : {}),
|
||||
};
|
||||
|
||||
export default knexConfig;
|
@@ -7,7 +7,8 @@
|
||||
"scripts": {
|
||||
"start-mock-license-server": "node ./license-server-with-mock.js",
|
||||
"test": "playwright test",
|
||||
"test:fast": "yarn test -j 90% --quiet --reporter null --ignore-snapshots -x"
|
||||
"test:fast": "yarn test -j 90% --quiet --reporter null --ignore-snapshots -x",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"contributors": [
|
||||
{
|
||||
@@ -25,14 +26,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.2.0",
|
||||
"@playwright/test": "^1.36.2"
|
||||
"@playwright/test": "^1.45.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8.13.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"luxon": "^3.4.4",
|
||||
"micro": "^10.0.1",
|
||||
"pg": "^8.12.0",
|
||||
"prettier": "^2.5.1"
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
const { defineConfig, devices } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
@@ -43,9 +42,19 @@ module.exports = defineConfig({
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /.*\.setup\.js/,
|
||||
teardown: 'teardown',
|
||||
},
|
||||
{
|
||||
name: 'teardown',
|
||||
testMatch: /.*\.teardown\.js/,
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
|
||||
// {
|
||||
|
29
packages/e2e-tests/tests/admin-setup/admin.setup.js
Normal file
29
packages/e2e-tests/tests/admin-setup/admin.setup.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const { publicTest: setup, expect } = require('../../fixtures/index');
|
||||
|
||||
setup.describe.serial('Admin setup page', () => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
setup('should not be able to login if admin is not created', async ({ page, adminSetupPage, loginPage }) => {
|
||||
await expect(async () => {
|
||||
await expect(await page.url()).toContain(adminSetupPage.path);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
setup('should validate the inputs', async ({ adminSetupPage }) => {
|
||||
await adminSetupPage.open();
|
||||
await adminSetupPage.fillInvalidUserData();
|
||||
await adminSetupPage.submitAdminForm();
|
||||
await adminSetupPage.expectInvalidFields(4);
|
||||
|
||||
await adminSetupPage.fillNotMatchingPasswordUserData();
|
||||
await adminSetupPage.submitAdminForm();
|
||||
await adminSetupPage.expectInvalidFields(1);
|
||||
});
|
||||
|
||||
setup('should create admin', async ({ adminSetupPage }) => {
|
||||
await adminSetupPage.open();
|
||||
await adminSetupPage.fillValidUserData();
|
||||
await adminSetupPage.submitAdminForm();
|
||||
await adminSetupPage.expectSuccessAlertToBeVisible();
|
||||
await adminSetupPage.expectSuccessMessageToContainLoginLink();
|
||||
});
|
||||
});
|
@@ -1,5 +1,6 @@
|
||||
const { test, expect } = require('../../fixtures/index');
|
||||
const { LoginPage } = require('../../fixtures/login-page');
|
||||
const { AcceptInvitation } = require('../../fixtures/accept-invitation-page');
|
||||
|
||||
test.describe('Role management page', () => {
|
||||
test('Admin role is not deletable', async ({ adminRolesPage }) => {
|
||||
@@ -16,7 +17,6 @@ test.describe('Role management page', () => {
|
||||
adminCreateRolePage,
|
||||
adminEditRolePage,
|
||||
adminRolesPage,
|
||||
page,
|
||||
}) => {
|
||||
await test.step('Create a new role', async () => {
|
||||
await adminRolesPage.navigateTo();
|
||||
@@ -125,12 +125,14 @@ test.describe('Role management page', () => {
|
||||
await adminCreateRolePage.isMounted();
|
||||
|
||||
const initScrollTop = await page.evaluate(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
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(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
return document.documentElement.scrollTop;
|
||||
});
|
||||
await expect(initScrollTop).not.toBe(updatedScrollTop);
|
||||
@@ -143,11 +145,13 @@ test.describe('Role management page', () => {
|
||||
await adminEditRolePage.isMounted();
|
||||
|
||||
const initScrollTop = await page.evaluate(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
return document.documentElement.scrollTop;
|
||||
});
|
||||
await page.mouse.move(400, 100);
|
||||
await page.mouse.wheel(200, 0);
|
||||
const updatedScrollTop = await page.evaluate(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
return document.documentElement.scrollTop;
|
||||
});
|
||||
await expect(initScrollTop).not.toBe(updatedScrollTop);
|
||||
@@ -164,7 +168,6 @@ test.describe('Role management page', () => {
|
||||
adminUsersPage,
|
||||
adminCreateUserPage,
|
||||
adminEditUserPage,
|
||||
page,
|
||||
}) => {
|
||||
await adminRolesPage.navigateTo();
|
||||
await test.step('Create a new role', async () => {
|
||||
@@ -190,13 +193,15 @@ test.describe('Role management page', () => {
|
||||
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', exact: true })
|
||||
.click();
|
||||
await adminCreateUserPage.createButton.click();
|
||||
await adminUsersPage.snackbar.waitFor({
|
||||
await adminCreateUserPage.snackbar.waitFor({
|
||||
state: 'attached',
|
||||
});
|
||||
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
|
||||
state: 'attached',
|
||||
});
|
||||
const snackbar = await adminUsersPage.getSnackbarData(
|
||||
@@ -267,7 +272,6 @@ test.describe('Role management page', () => {
|
||||
adminRolesPage,
|
||||
adminUsersPage,
|
||||
adminCreateUserPage,
|
||||
page,
|
||||
}) => {
|
||||
await adminRolesPage.navigateTo();
|
||||
await test.step('Create a new role', async () => {
|
||||
@@ -292,7 +296,6 @@ test.describe('Role management page', () => {
|
||||
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' })
|
||||
@@ -301,6 +304,9 @@ test.describe('Role management page', () => {
|
||||
await adminCreateUserPage.snackbar.waitFor({
|
||||
state: 'attached',
|
||||
});
|
||||
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
|
||||
state: 'attached',
|
||||
});
|
||||
const snackbar = await adminCreateUserPage.getSnackbarData(
|
||||
'snackbar-create-user-success'
|
||||
);
|
||||
@@ -333,7 +339,7 @@ test.describe('Role management page', () => {
|
||||
state: 'attached',
|
||||
});
|
||||
/*
|
||||
* TODO: await snackbar - make assertions based on product
|
||||
* TODO: await snackbar - make assertions based on product
|
||||
* decisions
|
||||
const snackbar = await adminRolesPage.getSnackbarData();
|
||||
await expect(snackbar.variant).toBe('...');
|
||||
@@ -374,7 +380,6 @@ test('Accessibility of role management page', async ({
|
||||
await adminCreateUserPage.isMounted();
|
||||
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' })
|
||||
@@ -383,6 +388,9 @@ test('Accessibility of role management page', async ({
|
||||
await adminCreateUserPage.snackbar.waitFor({
|
||||
state: 'attached',
|
||||
});
|
||||
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
|
||||
state: 'attached',
|
||||
});
|
||||
const snackbar = await adminCreateUserPage.getSnackbarData(
|
||||
'snackbar-create-user-success'
|
||||
);
|
||||
@@ -391,10 +399,23 @@ test('Accessibility of role management page', async ({
|
||||
});
|
||||
|
||||
await test.step('Logout and login to the basic role user', async () => {
|
||||
const acceptInvitationLink = await adminCreateUserPage.acceptInvitationLink;
|
||||
console.log(acceptInvitationLink);
|
||||
const acceptInvitationUrl = await acceptInvitationLink.textContent();
|
||||
console.log(acceptInvitationUrl);
|
||||
const acceptInvitatonToken = acceptInvitationUrl.split('?token=')[1];
|
||||
|
||||
await page.getByTestId('profile-menu-button').click();
|
||||
await page.getByTestId('logout-item').click();
|
||||
// await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
const acceptInvitationPage = new AcceptInvitation(page);
|
||||
|
||||
await acceptInvitationPage.open(acceptInvitatonToken);
|
||||
|
||||
await acceptInvitationPage.acceptInvitation('sample');
|
||||
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
// await loginPage.isMounted();
|
||||
await loginPage.login('basic-role-test@automatisch.io', 'sample');
|
||||
await expect(loginPage.loginButton).not.toBeVisible();
|
||||
@@ -409,10 +430,16 @@ test('Accessibility of role management page', async ({
|
||||
await page.goto(url);
|
||||
await page.waitForTimeout(750);
|
||||
const isUnmounted = await page.evaluate(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
const root = document.querySelector('#root');
|
||||
|
||||
if (root) {
|
||||
return root.children.length === 0;
|
||||
// We have react query devtools only in dev env.
|
||||
// In production, there is nothing in root.
|
||||
// That's why `<= 1`.
|
||||
return root.children.length <= 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
await expect(isUnmounted).toBe(true);
|
||||
|
@@ -29,16 +29,20 @@ test.describe('User management page', () => {
|
||||
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();
|
||||
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
|
||||
state: 'attached'
|
||||
});
|
||||
|
||||
const snackbar = await adminUsersPage.getSnackbarData(
|
||||
'snackbar-create-user-success'
|
||||
);
|
||||
await expect(snackbar.variant).toBe('success');
|
||||
await adminUsersPage.navigateTo();
|
||||
await adminUsersPage.closeSnackbar();
|
||||
}
|
||||
);
|
||||
@@ -57,7 +61,7 @@ test.describe('User management page', () => {
|
||||
'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);
|
||||
await adminEditUserPage.waitForLoad(user.fullName);
|
||||
@@ -85,7 +89,7 @@ test.describe('User management page', () => {
|
||||
await adminUsersPage.clickDeleteUser(userRow);
|
||||
const modal = adminUsersPage.deleteUserModal;
|
||||
await modal.deleteButton.click();
|
||||
|
||||
|
||||
const snackbar = await adminUsersPage.getSnackbarData(
|
||||
'snackbar-delete-user-success'
|
||||
);
|
||||
@@ -94,7 +98,7 @@ test.describe('User management page', () => {
|
||||
await expect(userRow).not.toBeVisible(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'Creating a user which has been deleted',
|
||||
@@ -105,10 +109,10 @@ test.describe('User management page', () => {
|
||||
await test.step(
|
||||
'Create the test user',
|
||||
async () => {
|
||||
await adminUsersPage.navigateTo();
|
||||
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' }
|
||||
@@ -125,6 +129,7 @@ test.describe('User management page', () => {
|
||||
await test.step(
|
||||
'Delete the created user',
|
||||
async () => {
|
||||
await adminUsersPage.navigateTo();
|
||||
await adminUsersPage.findUserPageWithEmail(testUser.email);
|
||||
const userRow = await adminUsersPage.getUserRowByEmail(testUser.email);
|
||||
await adminUsersPage.clickDeleteUser(userRow);
|
||||
@@ -146,7 +151,6 @@ test.describe('User management page', () => {
|
||||
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' }
|
||||
@@ -179,7 +183,6 @@ test.describe('User management page', () => {
|
||||
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' }
|
||||
@@ -196,17 +199,17 @@ test.describe('User management page', () => {
|
||||
await test.step(
|
||||
'Create the user again',
|
||||
async () => {
|
||||
await adminUsersPage.navigateTo();
|
||||
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');
|
||||
@@ -227,10 +230,10 @@ test.describe('User management page', () => {
|
||||
await test.step(
|
||||
'Create the first user',
|
||||
async () => {
|
||||
await adminUsersPage.navigateTo();
|
||||
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' }
|
||||
@@ -247,10 +250,10 @@ test.describe('User management page', () => {
|
||||
await test.step(
|
||||
'Create the second user',
|
||||
async () => {
|
||||
await adminUsersPage.navigateTo();
|
||||
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' }
|
||||
@@ -267,6 +270,7 @@ test.describe('User management page', () => {
|
||||
await test.step(
|
||||
'Try editing the second user to have the email of the first user',
|
||||
async () => {
|
||||
await adminUsersPage.navigateTo();
|
||||
await adminUsersPage.findUserPageWithEmail(user2.email);
|
||||
let userRow = await adminUsersPage.getUserRowByEmail(user2.email);
|
||||
await adminUsersPage.clickEditUser(userRow);
|
||||
@@ -285,4 +289,4 @@ test.describe('User management page', () => {
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@@ -51,7 +51,7 @@ test(
|
||||
|
||||
const subjects = ['Connection', 'Execution', 'Flow'];
|
||||
for (let subject of subjects) {
|
||||
const row = adminCreateRolePage.getSubjectRow(subject)
|
||||
const row = adminCreateRolePage.getSubjectRow(subject);
|
||||
const modal = adminCreateRolePage.getRoleConditionsModal(subject);
|
||||
await adminCreateRolePage.clickPermissionSettings(row);
|
||||
await expect(modal.modal).toBeVisible();
|
||||
|
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
const { test, expect } = require('../../fixtures/index');
|
||||
|
||||
test.describe('Apps page', () => {
|
||||
|
@@ -1,5 +1,4 @@
|
||||
// @ts-check
|
||||
const { publicTest, test, expect } = require('../../fixtures/index');
|
||||
const { publicTest, expect } = require('../../fixtures/index');
|
||||
|
||||
publicTest.describe('Login page', () => {
|
||||
publicTest('shows login form', async ({ loginPage }) => {
|
||||
|
@@ -1,8 +1,7 @@
|
||||
// @ts-check
|
||||
const { test, expect } = require('../../fixtures/index');
|
||||
|
||||
test.describe('Connections page', () => {
|
||||
test.beforeEach(async ({ page, connectionsPage }) => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.getByTestId('apps-page-drawer-link').click();
|
||||
await page.goto('/app/ntfy/connections');
|
||||
});
|
||||
@@ -36,6 +35,7 @@ test.describe('Connections page', () => {
|
||||
}) => {
|
||||
await connectionsPage.clickAddConnectionButton();
|
||||
await expect(page).toHaveURL('/app/ntfy/connections/add?shared=false');
|
||||
await expect(page.getByTestId('create-connection-button')).not.toBeDisabled();
|
||||
await page.getByTestId('create-connection-button').click();
|
||||
await expect(
|
||||
page.getByTestId('create-connection-button')
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user