Compare commits

..

1 Commits

Author SHA1 Message Date
Ali BARIN
f36155f3a5 chore(coverage): add redundant code to test coverage threshold 2024-11-25 09:01:26 +00:00
49 changed files with 410 additions and 1322 deletions

View File

@@ -3,13 +3,12 @@ on:
push: push:
branches: branches:
- main - main
# TODO: Add pull request after optimizing the total excecution time of the test suite. pull_request:
# pull_request: paths:
# paths: - 'packages/backend/**'
# - 'packages/backend/**' - 'packages/e2e-tests/**'
# - 'packages/e2e-tests/**' - 'packages/web/**'
# - 'packages/web/**' - '!packages/backend/src/apps/**'
# - '!packages/backend/src/apps/**'
workflow_dispatch: workflow_dispatch:
env: env:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`RoleMapping model > jsonSchema should have the correct schema 1`] = ` exports[`SamlAuthProvidersRoleMapping model > jsonSchema should have the correct schema 1`] = `
{ {
"properties": { "properties": {
"id": { "id": {

View File

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

View File

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

View File

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

View File

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

View File

@@ -212,10 +212,6 @@ class User extends Base {
return `${appConfig.webAppUrl}/accept-invitation?token=${this.invitationToken}`; return `${appConfig.webAppUrl}/accept-invitation?token=${this.invitationToken}`;
} }
get ability() {
return userAbility(this);
}
static async authenticate(email, password) { static async authenticate(email, password) {
const user = await User.query().findOne({ const user = await User.query().findOne({
email: email?.toLowerCase() || null, email: email?.toLowerCase() || null,
@@ -370,6 +366,18 @@ class User extends Base {
return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds; return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds;
} }
toTestTestCoverage() {
if (!this.resetPasswordTokenSentAt) {
return false;
}
const sentAt = new Date(this.resetPasswordTokenSentAt);
const now = new Date();
const fourHoursInMilliseconds = 1000 * 60 * 60 * 4;
return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds;
}
async sendInvitationEmail() { async sendInvitationEmail() {
await this.generateInvitationToken(); await this.generateInvitationToken();
@@ -587,6 +595,62 @@ class User extends Base {
return user; return user;
} }
async $beforeInsert(queryContext) {
await super.$beforeInsert(queryContext);
this.email = this.email.toLowerCase();
await this.generateHash();
if (appConfig.isCloud) {
this.startTrialPeriod();
}
}
async $beforeUpdate(opt, queryContext) {
await super.$beforeUpdate(opt, queryContext);
if (this.email) {
this.email = this.email.toLowerCase();
}
await this.generateHash();
}
async $afterInsert(queryContext) {
await super.$afterInsert(queryContext);
if (appConfig.isCloud) {
await this.$relatedQuery('usageData').insert({
userId: this.id,
consumedTaskCount: 0,
nextResetAt: DateTime.now().plus({ days: 30 }).toISODate(),
});
}
}
async $afterFind() {
if (await hasValidLicense()) return this;
if (Array.isArray(this.permissions)) {
this.permissions = this.permissions.filter((permission) => {
const restrictedSubjects = [
'App',
'Role',
'SamlAuthProvider',
'Config',
];
return !restrictedSubjects.includes(permission.subject);
});
}
return this;
}
get ability() {
return userAbility(this);
}
can(action, subject) { can(action, subject) {
const can = this.ability.can(action, subject); const can = this.ability.can(action, subject);
@@ -602,68 +666,12 @@ class User extends Base {
return conditionMap; return conditionMap;
} }
lowercaseEmail() { cannot(action, subject) {
if (this.email) { const cannot = this.ability.cannot(action, subject);
this.email = this.email.toLowerCase();
}
}
async createUsageData() { if (cannot) throw new NotAuthorizedError();
if (appConfig.isCloud) {
return await this.$relatedQuery('usageData').insertAndFetch({
userId: this.id,
consumedTaskCount: 0,
nextResetAt: DateTime.now().plus({ days: 30 }).toISODate(),
});
}
}
async omitEnterprisePermissionsWithoutValidLicense() { return cannot;
if (await hasValidLicense()) {
return this;
}
if (Array.isArray(this.permissions)) {
this.permissions = this.permissions.filter((permission) => {
const restrictedSubjects = [
'App',
'Role',
'SamlAuthProvider',
'Config',
];
return !restrictedSubjects.includes(permission.subject);
});
}
}
async $beforeInsert(queryContext) {
await super.$beforeInsert(queryContext);
this.lowercaseEmail();
await this.generateHash();
if (appConfig.isCloud) {
this.startTrialPeriod();
}
}
async $beforeUpdate(opt, queryContext) {
await super.$beforeUpdate(opt, queryContext);
this.lowercaseEmail();
await this.generateHash();
}
async $afterInsert(queryContext) {
await super.$afterInsert(queryContext);
await this.createUsageData();
}
async $afterFind() {
await this.omitEnterprisePermissionsWithoutValidLicense();
} }
} }

View File

@@ -1,10 +1,8 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { DateTime, Duration } from 'luxon'; import { DateTime, Duration } from 'luxon';
import appConfig from '../config/app.js'; import appConfig from '../config/app.js';
import * as licenseModule from '../helpers/license.ee.js';
import Base from './base.js'; import Base from './base.js';
import AccessToken from './access-token.js'; import AccessToken from './access-token.js';
import Config from './config.js';
import Connection from './connection.js'; import Connection from './connection.js';
import Execution from './execution.js'; import Execution from './execution.js';
import Flow from './flow.js'; import Flow from './flow.js';
@@ -21,7 +19,6 @@ import {
REMOVE_AFTER_30_DAYS_OR_150_JOBS, REMOVE_AFTER_30_DAYS_OR_150_JOBS,
REMOVE_AFTER_7_DAYS_OR_50_JOBS, REMOVE_AFTER_7_DAYS_OR_50_JOBS,
} from '../helpers/remove-job-configuration.js'; } from '../helpers/remove-job-configuration.js';
import * as userAbilityModule from '../helpers/user-ability.js';
import { createUser } from '../../test/factories/user.js'; import { createUser } from '../../test/factories/user.js';
import { createConnection } from '../../test/factories/connection.js'; import { createConnection } from '../../test/factories/connection.js';
import { createRole } from '../../test/factories/role.js'; import { createRole } from '../../test/factories/role.js';
@@ -29,9 +26,6 @@ import { createPermission } from '../../test/factories/permission.js';
import { createFlow } from '../../test/factories/flow.js'; import { createFlow } from '../../test/factories/flow.js';
import { createStep } from '../../test/factories/step.js'; import { createStep } from '../../test/factories/step.js';
import { createExecution } from '../../test/factories/execution.js'; import { createExecution } from '../../test/factories/execution.js';
import { createSubscription } from '../../test/factories/subscription.js';
import { createUsageData } from '../../test/factories/usage-data.js';
import Billing from '../helpers/billing/index.ee.js';
describe('User model', () => { describe('User model', () => {
it('tableName should return correct name', () => { it('tableName should return correct name', () => {
@@ -207,6 +201,64 @@ describe('User model', () => {
expect(virtualAttributes).toStrictEqual(expectedAttributes); expect(virtualAttributes).toStrictEqual(expectedAttributes);
}); });
it('acceptInvitationUrl should return accept invitation page URL with invitation token', async () => {
const user = new User();
user.invitationToken = 'invitation-token';
vi.spyOn(appConfig, 'webAppUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
expect(user.acceptInvitationUrl).toBe(
'https://automatisch.io/accept-invitation?token=invitation-token'
);
});
describe('authenticate', () => {
it('should create and return the token for correct email and password', async () => {
const user = await createUser({
email: 'test-user@automatisch.io',
password: 'sample-password',
});
const token = await User.authenticate(
'test-user@automatisch.io',
'sample-password'
);
const persistedToken = await AccessToken.query().findOne({
userId: user.id,
});
expect(token).toBe(persistedToken.token);
});
it('should return undefined for existing email and incorrect password', async () => {
await createUser({
email: 'test-user@automatisch.io',
password: 'sample-password',
});
const token = await User.authenticate(
'test-user@automatisch.io',
'wrong-password'
);
expect(token).toBe(undefined);
});
it('should return undefined for non-existing email', async () => {
await createUser({
email: 'test-user@automatisch.io',
password: 'sample-password',
});
const token = await User.authenticate('non-existing-user@automatisch.io');
expect(token).toBe(undefined);
});
});
describe('authorizedFlows', () => { describe('authorizedFlows', () => {
it('should return user flows with isCreator condition', async () => { it('should return user flows with isCreator condition', async () => {
const userRole = await createRole({ name: 'User' }); const userRole = await createRole({ name: 'User' });
@@ -449,76 +501,6 @@ describe('User model', () => {
}); });
}); });
it('acceptInvitationUrl should return accept invitation page URL with invitation token', async () => {
const user = new User();
user.invitationToken = 'invitation-token';
vi.spyOn(appConfig, 'webAppUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
expect(user.acceptInvitationUrl).toBe(
'https://automatisch.io/accept-invitation?token=invitation-token'
);
});
it('ability should return userAbility for the user', () => {
const user = new User();
user.fullName = 'Sample user';
const userAbilitySpy = vi
.spyOn(userAbilityModule, 'default')
.mockReturnValue('user-ability');
expect(user.ability).toStrictEqual('user-ability');
expect(userAbilitySpy).toHaveBeenNthCalledWith(1, user);
});
describe('authenticate', () => {
it('should create and return the token for correct email and password', async () => {
const user = await createUser({
email: 'test-user@automatisch.io',
password: 'sample-password',
});
const token = await User.authenticate(
'test-user@automatisch.io',
'sample-password'
);
const persistedToken = await AccessToken.query().findOne({
userId: user.id,
});
expect(token).toBe(persistedToken.token);
});
it('should return undefined for existing email and incorrect password', async () => {
await createUser({
email: 'test-user@automatisch.io',
password: 'sample-password',
});
const token = await User.authenticate(
'test-user@automatisch.io',
'wrong-password'
);
expect(token).toBe(undefined);
});
it('should return undefined for non-existing email', async () => {
await createUser({
email: 'test-user@automatisch.io',
password: 'sample-password',
});
const token = await User.authenticate('non-existing-user@automatisch.io');
expect(token).toBe(undefined);
});
});
describe('login', () => { describe('login', () => {
it('should return true when the given password matches with the user password', async () => { it('should return true when the given password matches with the user password', async () => {
const user = await createUser({ password: 'sample-password' }); const user = await createUser({ password: 'sample-password' });
@@ -893,637 +875,4 @@ describe('User model', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
describe('isAllowedToRunFlows', () => {
it('should return true when Automatisch is self hosted', async () => {
const user = new User();
vi.spyOn(appConfig, 'isSelfHosted', 'get').mockReturnValue(true);
expect(await user.isAllowedToRunFlows()).toBe(true);
});
it('should return true when the user is in trial', async () => {
const user = new User();
vi.spyOn(user, 'inTrial').mockResolvedValue(true);
expect(await user.isAllowedToRunFlows()).toBe(true);
});
it('should return true when the user has active subscription and within quota limits', async () => {
const user = new User();
vi.spyOn(user, 'hasActiveSubscription').mockResolvedValue(true);
vi.spyOn(user, 'withinLimits').mockResolvedValue(true);
expect(await user.isAllowedToRunFlows()).toBe(true);
});
it('should return false when the user has active subscription over quota limits', async () => {
const user = new User();
vi.spyOn(user, 'hasActiveSubscription').mockResolvedValue(true);
vi.spyOn(user, 'withinLimits').mockResolvedValue(false);
expect(await user.isAllowedToRunFlows()).toBe(false);
});
it('should return false otherwise', async () => {
const user = new User();
expect(await user.isAllowedToRunFlows()).toBe(false);
});
});
describe('inTrial', () => {
it('should return false when Automatisch is self hosted', async () => {
const user = new User();
vi.spyOn(appConfig, 'isSelfHosted', 'get').mockReturnValue(true);
expect(await user.inTrial()).toBe(false);
});
it('should return false when the user does not have trial expiry date', async () => {
const user = new User();
vi.spyOn(appConfig, 'isSelfHosted', 'get').mockReturnValue(false);
expect(await user.inTrial()).toBe(false);
});
it('should return false when the user has an active subscription', async () => {
const user = new User();
user.trialExpiryDate = '2024-12-14';
vi.spyOn(appConfig, 'isSelfHosted', 'get').mockReturnValue(false);
const hasActiveSubscriptionSpy = vi
.spyOn(user, 'hasActiveSubscription')
.mockResolvedValue(true);
expect(await user.inTrial()).toBe(false);
expect(hasActiveSubscriptionSpy).toHaveBeenCalledOnce();
});
it('should return true when trial expiry date is in future', async () => {
vi.useFakeTimers();
const date = DateTime.fromObject(
{ year: 2024, month: 11, day: 12, hour: 17, minute: 30 },
{ zone: 'UTC+0' }
);
vi.setSystemTime(date);
const user = await createUser();
await user.startTrialPeriod();
const refetchedUser = await user.$query();
vi.spyOn(appConfig, 'isSelfHosted', 'get').mockReturnValue(false);
vi.spyOn(refetchedUser, 'hasActiveSubscription').mockResolvedValue(false);
expect(await refetchedUser.inTrial()).toBe(true);
vi.useRealTimers();
});
it('should return false when trial expiry date is in past', async () => {
vi.useFakeTimers();
const user = await createUser();
await user.startTrialPeriod();
vi.setSystemTime(DateTime.now().plus({ month: 1 }));
const refetchedUser = await user.$query();
vi.spyOn(appConfig, 'isSelfHosted', 'get').mockReturnValue(false);
vi.spyOn(refetchedUser, 'hasActiveSubscription').mockResolvedValue(false);
expect(await refetchedUser.inTrial()).toBe(false);
vi.useRealTimers();
});
});
describe('hasActiveSubscription', () => {
it('should return true if current subscription is valid', async () => {
const user = await createUser();
await createSubscription({ userId: user.id, status: 'active' });
expect(await user.hasActiveSubscription()).toBe(true);
});
it('should return false if current subscription is not valid', async () => {
const user = await createUser();
await createSubscription({
userId: user.id,
status: 'deleted',
cancellationEffectiveDate: DateTime.now().minus({ day: 1 }).toString(),
});
expect(await user.hasActiveSubscription()).toBe(false);
});
it('should return false if Automatisch is not a cloud installation', async () => {
const user = new User();
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false);
expect(await user.hasActiveSubscription()).toBe(false);
});
});
describe('withinLimits', () => {
it('should return true when the consumed task count is less than the quota', async () => {
const user = await createUser();
const subscription = await createSubscription({ userId: user.id });
await createUsageData({
subscriptionId: subscription.id,
userId: user.id,
consumedTaskCount: 100,
});
expect(await user.withinLimits()).toBe(true);
});
it('should return true when the consumed task count is less than the quota', async () => {
const user = await createUser();
const subscription = await createSubscription({ userId: user.id });
await createUsageData({
subscriptionId: subscription.id,
userId: user.id,
consumedTaskCount: 10000,
});
expect(await user.withinLimits()).toBe(false);
});
});
describe('getPlanAndUsage', () => {
it('should return plan and usage', async () => {
const user = await createUser();
const subscription = await createSubscription({ userId: user.id });
expect(await user.getPlanAndUsage()).toStrictEqual({
usage: {
task: 0,
},
plan: {
id: subscription.paddlePlanId,
name: '10k - monthly',
limit: '10,000',
},
});
});
it('should return trial plan and usage if no subscription exists', async () => {
const user = await createUser();
expect(await user.getPlanAndUsage()).toStrictEqual({
usage: {
task: 0,
},
plan: {
id: null,
name: 'Free Trial',
limit: null,
},
});
});
it('should throw not found when the current usage data does not exist', async () => {
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false);
const user = await createUser();
await expect(() => user.getPlanAndUsage()).rejects.toThrow(
'NotFoundError'
);
});
});
describe('getInvoices', () => {
it('should return invoices for the current subscription', async () => {
const user = await createUser();
const subscription = await createSubscription({ userId: user.id });
const getInvoicesSpy = vi
.spyOn(Billing.paddleClient, 'getInvoices')
.mockResolvedValue('dummy-invoices');
expect(await user.getInvoices()).toBe('dummy-invoices');
expect(getInvoicesSpy).toHaveBeenCalledWith(
Number(subscription.paddleSubscriptionId)
);
});
it('should return empty array without any subscriptions', async () => {
const user = await createUser();
expect(await user.getInvoices()).toStrictEqual([]);
});
});
it.todo('getApps');
it('createAdmin should create admin with given data and mark the installation completed', async () => {
const adminRole = await createRole({ name: 'Admin' });
const markInstallationCompletedSpy = vi
.spyOn(Config, 'markInstallationCompleted')
.mockResolvedValue();
const adminUser = await User.createAdmin({
fullName: 'Sample admin',
email: 'admin@automatisch.io',
password: 'sample',
});
expect(adminUser).toMatchObject({
fullName: 'Sample admin',
email: 'admin@automatisch.io',
roleId: adminRole.id,
});
expect(markInstallationCompletedSpy).toHaveBeenCalledOnce();
expect(await adminUser.login('sample')).toBe(true);
});
describe('registerUser', () => {
it('should register user with user role and given data', async () => {
const userRole = await createRole({ name: 'User' });
const user = await User.registerUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
password: 'sample-password',
});
expect(user).toMatchObject({
fullName: 'Sample user',
email: 'user@automatisch.io',
roleId: userRole.id,
});
expect(await user.login('sample-password')).toBe(true);
});
it('should throw not found error when user role does not exist', async () => {
await expect(() =>
User.registerUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
password: 'sample-password',
})
).rejects.toThrowError('NotFoundError');
});
});
describe('can', () => {
it('should return conditions for the given action and subject of the user', async () => {
const userRole = await createRole({ name: 'User' });
await createPermission({
roleId: userRole.id,
subject: 'Flow',
action: 'read',
conditions: ['isCreator'],
});
await createPermission({
roleId: userRole.id,
subject: 'Connection',
action: 'read',
conditions: [],
});
const user = await createUser({ roleId: userRole.id });
const userWithRoleAndPermissions = await user
.$query()
.withGraphFetched({ role: true, permissions: true });
expect(userWithRoleAndPermissions.can('read', 'Flow')).toStrictEqual({
isCreator: true,
});
expect(
userWithRoleAndPermissions.can('read', 'Connection')
).toStrictEqual({});
});
it('should return not authorized error when the user is not permitted for the given action and subject', async () => {
const userRole = await createRole({ name: 'User' });
const user = await createUser({ roleId: userRole.id });
const userWithRoleAndPermissions = await user
.$query()
.withGraphFetched({ role: true, permissions: true });
expect(() => userWithRoleAndPermissions.can('read', 'Flow')).toThrowError(
'The user is not authorized!'
);
});
});
it('lowercaseEmail should lowercase the user email', () => {
const user = new User();
user.email = 'USER@AUTOMATISCH.IO';
user.lowercaseEmail();
expect(user.email).toBe('user@automatisch.io');
});
describe('createUsageData', () => {
it('should create usage data if Automatisch is a cloud installation', async () => {
vi.useFakeTimers();
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true);
const user = await createUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
});
vi.setSystemTime(DateTime.now().plus({ month: 1 }));
const usageData = await user.createUsageData();
const currentUsageData = await user.$relatedQuery('currentUsageData');
expect(usageData).toStrictEqual(currentUsageData);
vi.useRealTimers();
});
it('should not create usage data if Automatisch is not a cloud installation', async () => {
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false);
const user = await createUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
});
const usageData = await user.createUsageData();
expect(usageData).toBe(undefined);
});
});
describe('omitEnterprisePermissionsWithoutValidLicense', () => {
it('should return user as-is with valid license', async () => {
const userRole = await createRole({ name: 'User' });
const user = await createUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
roleId: userRole.id,
});
const readFlowPermission = await createPermission({
roleId: userRole.id,
subject: 'Flow',
action: 'read',
conditions: [],
});
await createPermission({
roleId: userRole.id,
subject: 'App',
action: 'read',
conditions: [],
});
await createPermission({
roleId: userRole.id,
subject: 'Role',
action: 'read',
conditions: [],
});
await createPermission({
roleId: userRole.id,
subject: 'Config',
action: 'read',
conditions: [],
});
await createPermission({
roleId: userRole.id,
subject: 'SamlAuthProvider',
action: 'read',
conditions: [],
});
const userWithRoleAndPermissions = await user
.$query()
.withGraphFetched({ role: true, permissions: true });
expect(userWithRoleAndPermissions.permissions).toStrictEqual([
readFlowPermission,
]);
});
it('should omit enterprise permissions without valid license', async () => {
vi.spyOn(licenseModule, 'hasValidLicense').mockResolvedValue(false);
const userRole = await createRole({ name: 'User' });
const user = await createUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
roleId: userRole.id,
});
const readFlowPermission = await createPermission({
roleId: userRole.id,
subject: 'Flow',
action: 'read',
conditions: [],
});
await createPermission({
roleId: userRole.id,
subject: 'App',
action: 'read',
conditions: [],
});
await createPermission({
roleId: userRole.id,
subject: 'Role',
action: 'read',
conditions: [],
});
await createPermission({
roleId: userRole.id,
subject: 'Config',
action: 'read',
conditions: [],
});
await createPermission({
roleId: userRole.id,
subject: 'SamlAuthProvider',
action: 'read',
conditions: [],
});
const userWithRoleAndPermissions = await user
.$query()
.withGraphFetched({ role: true, permissions: true });
expect(userWithRoleAndPermissions.permissions).toStrictEqual([
readFlowPermission,
]);
});
});
describe('$beforeInsert', () => {
it('should call super.$beforeInsert', async () => {
const superBeforeInsertSpy = vi
.spyOn(User.prototype, '$beforeInsert')
.mockResolvedValue();
await createUser();
expect(superBeforeInsertSpy).toHaveBeenCalledOnce();
});
it('should lowercase the user email', async () => {
const user = await createUser({
fullName: 'Sample user',
email: 'USER@AUTOMATISCH.IO',
});
expect(user.email).toBe('user@automatisch.io');
});
it('should generate password hash', async () => {
const user = await createUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
password: 'sample-password',
});
expect(user.password).not.toBe('sample-password');
expect(await user.login('sample-password')).toBe(true);
});
it('should start trial period if Automatisch is a cloud installation', async () => {
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true);
const startTrialPeriodSpy = vi.spyOn(User.prototype, 'startTrialPeriod');
await createUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
});
expect(startTrialPeriodSpy).toHaveBeenCalledOnce();
});
it('should not start trial period if Automatisch is not a cloud installation', async () => {
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false);
const startTrialPeriodSpy = vi.spyOn(User.prototype, 'startTrialPeriod');
await createUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
});
expect(startTrialPeriodSpy).not.toHaveBeenCalled();
});
});
describe('$beforeUpdate', () => {
it('should call super.$beforeUpdate', async () => {
const superBeforeUpdateSpy = vi
.spyOn(User.prototype, '$beforeUpdate')
.mockResolvedValue();
const user = await createUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
});
await user.$query().patch({ fullName: 'Updated user name' });
expect(superBeforeUpdateSpy).toHaveBeenCalledOnce();
});
it('should lowercase the user email if given', async () => {
const user = await createUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
});
await user.$query().patchAndFetch({ email: 'NEW_EMAIL@AUTOMATISCH.IO' });
expect(user.email).toBe('new_email@automatisch.io');
});
it('should generate password hash', async () => {
const user = await createUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
password: 'sample-password',
});
await user.$query().patchAndFetch({ password: 'new-password' });
expect(user.password).not.toBe('new-password');
expect(await user.login('new-password')).toBe(true);
});
});
describe('$afterInsert', () => {
it('should call super.$afterInsert', async () => {
const superAfterInsertSpy = vi.spyOn(User.prototype, '$afterInsert');
await createUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
});
expect(superAfterInsertSpy).toHaveBeenCalledOnce();
});
it('should call createUsageData', async () => {
const createUsageDataSpy = vi.spyOn(User.prototype, 'createUsageData');
await createUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
});
expect(createUsageDataSpy).toHaveBeenCalledOnce();
});
});
it('$afterFind should invoke omitEnterprisePermissionsWithoutValidLicense method', async () => {
const omitEnterprisePermissionsWithoutValidLicenseSpy = vi.spyOn(
User.prototype,
'omitEnterprisePermissionsWithoutValidLicense'
);
await createUser({
fullName: 'Sample user',
email: 'user@automatisch.io',
});
expect(
omitEnterprisePermissionsWithoutValidLicenseSpy
).toHaveBeenCalledOnce();
});
}); });

View File

@@ -11,6 +11,10 @@ const redisConnection = {
const actionQueue = new Queue('action', redisConnection); const actionQueue = new Queue('action', redisConnection);
process.on('SIGTERM', async () => {
await actionQueue.close();
});
actionQueue.on('error', (error) => { actionQueue.on('error', (error) => {
if (error.code === CONNECTION_REFUSED) { if (error.code === CONNECTION_REFUSED) {
logger.error( logger.error(

View File

@@ -11,6 +11,10 @@ const redisConnection = {
const deleteUserQueue = new Queue('delete-user', redisConnection); const deleteUserQueue = new Queue('delete-user', redisConnection);
process.on('SIGTERM', async () => {
await deleteUserQueue.close();
});
deleteUserQueue.on('error', (error) => { deleteUserQueue.on('error', (error) => {
if (error.code === CONNECTION_REFUSED) { if (error.code === CONNECTION_REFUSED) {
logger.error( logger.error(

View File

@@ -11,6 +11,10 @@ const redisConnection = {
const emailQueue = new Queue('email', redisConnection); const emailQueue = new Queue('email', redisConnection);
process.on('SIGTERM', async () => {
await emailQueue.close();
});
emailQueue.on('error', (error) => { emailQueue.on('error', (error) => {
if (error.code === CONNECTION_REFUSED) { if (error.code === CONNECTION_REFUSED) {
logger.error( logger.error(

View File

@@ -11,6 +11,10 @@ const redisConnection = {
const flowQueue = new Queue('flow', redisConnection); const flowQueue = new Queue('flow', redisConnection);
process.on('SIGTERM', async () => {
await flowQueue.close();
});
flowQueue.on('error', (error) => { flowQueue.on('error', (error) => {
if (error.code === CONNECTION_REFUSED) { if (error.code === CONNECTION_REFUSED) {
logger.error( logger.error(

View File

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

View File

@@ -14,6 +14,10 @@ const removeCancelledSubscriptionsQueue = new Queue(
redisConnection redisConnection
); );
process.on('SIGTERM', async () => {
await removeCancelledSubscriptionsQueue.close();
});
removeCancelledSubscriptionsQueue.on('error', (error) => { removeCancelledSubscriptionsQueue.on('error', (error) => {
if (error.code === CONNECTION_REFUSED) { if (error.code === CONNECTION_REFUSED) {
logger.error( logger.error(

View File

@@ -11,6 +11,10 @@ const redisConnection = {
const triggerQueue = new Queue('trigger', redisConnection); const triggerQueue = new Queue('trigger', redisConnection);
process.on('SIGTERM', async () => {
await triggerQueue.close();
});
triggerQueue.on('error', (error) => { triggerQueue.on('error', (error) => {
if (error.code === CONNECTION_REFUSED) { if (error.code === CONNECTION_REFUSED) {
logger.error( logger.error(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ export class DeleteRoleModal {
this.modal = page.getByTestId('delete-role-modal'); this.modal = page.getByTestId('delete-role-modal');
this.cancelButton = this.modal.getByTestId('confirmation-cancel-button'); this.cancelButton = this.modal.getByTestId('confirmation-cancel-button');
this.deleteButton = this.modal.getByTestId('confirmation-confirm-button'); this.deleteButton = this.modal.getByTestId('confirmation-confirm-button');
this.deleteAlert = this.modal.getByTestId('confirmation-dialog-error-alert');
} }
async close () { async close () {

View File

@@ -218,7 +218,12 @@ test.describe('Role management page', () => {
const row = await adminRolesPage.getRoleRowByName('Delete Role'); const row = await adminRolesPage.getRoleRowByName('Delete Role');
const modal = await adminRolesPage.clickDeleteRole(row); const modal = await adminRolesPage.clickDeleteRole(row);
await modal.deleteButton.click(); await modal.deleteButton.click();
await expect(modal.deleteAlert).toHaveCount(1); await adminRolesPage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminRolesPage.getSnackbarData('snackbar-delete-role-error');
await expect(snackbar.variant).toBe('error');
await adminRolesPage.closeSnackbar();
await modal.close(); await modal.close();
} }
); );
@@ -313,6 +318,7 @@ test.describe('Role management page', () => {
const row = await adminUsersPage.findUserPageWithEmail( const row = await adminUsersPage.findUserPageWithEmail(
'user-delete-role-test@automatisch.io' 'user-delete-role-test@automatisch.io'
); );
// await test.waitForTimeout(10000);
const modal = await adminUsersPage.clickDeleteUser(row); const modal = await adminUsersPage.clickDeleteUser(row);
await modal.deleteButton.click(); await modal.deleteButton.click();
await adminUsersPage.snackbar.waitFor({ await adminUsersPage.snackbar.waitFor({
@@ -329,7 +335,15 @@ test.describe('Role management page', () => {
const row = await adminRolesPage.getRoleRowByName('Cannot Delete Role'); const row = await adminRolesPage.getRoleRowByName('Cannot Delete Role');
const modal = await adminRolesPage.clickDeleteRole(row); const modal = await adminRolesPage.clickDeleteRole(row);
await modal.deleteButton.click(); await modal.deleteButton.click();
await expect(modal.deleteAlert).toHaveCount(1); await adminRolesPage.snackbar.waitFor({
state: 'attached',
});
/*
* TODO: await snackbar - make assertions based on product
* decisions
const snackbar = await adminRolesPage.getSnackbarData();
await expect(snackbar.variant).toBe('...');
*/
await adminRolesPage.closeSnackbar(); await adminRolesPage.closeSnackbar();
}); });
}); });

View File

@@ -6,7 +6,6 @@ import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText'; import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import Alert from '@mui/material/Alert';
function ConfirmationDialog(props) { function ConfirmationDialog(props) {
const { const {
@@ -17,7 +16,6 @@ function ConfirmationDialog(props) {
cancelButtonChildren, cancelButtonChildren,
confirmButtonChildren, confirmButtonChildren,
open = true, open = true,
errorMessage,
} = props; } = props;
const dataTest = props['data-test']; const dataTest = props['data-test'];
return ( return (
@@ -46,11 +44,6 @@ function ConfirmationDialog(props) {
</Button> </Button>
)} )}
</DialogActions> </DialogActions>
{errorMessage && (
<Alert data-test="confirmation-dialog-error-alert" severity="error">
{errorMessage}
</Alert>
)}
</Dialog> </Dialog>
); );
} }
@@ -64,7 +57,6 @@ ConfirmationDialog.propTypes = {
confirmButtonChildren: PropTypes.node.isRequired, confirmButtonChildren: PropTypes.node.isRequired,
open: PropTypes.bool, open: PropTypes.bool,
'data-test': PropTypes.string, 'data-test': PropTypes.string,
errorMessage: PropTypes.string,
}; };
export default ConfirmationDialog; export default ConfirmationDialog;

View File

@@ -4,7 +4,6 @@ import IconButton from '@mui/material/IconButton';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import * as React from 'react'; import * as React from 'react';
import { getGeneralErrorMessage, getFieldErrorMessage } from 'helpers/errors';
import Can from 'components/Can'; import Can from 'components/Can';
import ConfirmationDialog from 'components/ConfirmationDialog'; import ConfirmationDialog from 'components/ConfirmationDialog';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
@@ -16,21 +15,7 @@ function DeleteRoleButton(props) {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const enqueueSnackbar = useEnqueueSnackbar(); const enqueueSnackbar = useEnqueueSnackbar();
const { const { mutateAsync: deleteRole } = useAdminDeleteRole(roleId);
mutateAsync: deleteRole,
error: deleteRoleError,
reset: resetDeleteRole,
} = useAdminDeleteRole(roleId);
const roleErrorMessage = getFieldErrorMessage({
fieldName: 'role',
error: deleteRoleError,
});
const generalErrorMessage = getGeneralErrorMessage({
error: deleteRoleError,
fallbackMessage: formatMessage('deleteRoleButton.generalError'),
});
const handleConfirm = React.useCallback(async () => { const handleConfirm = React.useCallback(async () => {
try { try {
@@ -43,13 +28,23 @@ function DeleteRoleButton(props) {
'data-test': 'snackbar-delete-role-success', 'data-test': 'snackbar-delete-role-success',
}, },
}); });
} catch {} } catch (error) {
}, [deleteRole, enqueueSnackbar, formatMessage]); const errors = Object.values(
error.response.data.errors || [['Failed while deleting!']],
);
const handleClose = () => { for (const [error] of errors) {
setShowConfirmation(false); enqueueSnackbar(error, {
resetDeleteRole(); variant: 'error',
}; SnackbarProps: {
'data-test': 'snackbar-delete-role-error',
},
});
}
throw new Error('Failed while deleting!');
}
}, [deleteRole, enqueueSnackbar, formatMessage]);
return ( return (
<> <>
@@ -70,12 +65,11 @@ function DeleteRoleButton(props) {
open={showConfirmation} open={showConfirmation}
title={formatMessage('deleteRoleButton.title')} title={formatMessage('deleteRoleButton.title')}
description={formatMessage('deleteRoleButton.description')} description={formatMessage('deleteRoleButton.description')}
onClose={handleClose} onClose={() => setShowConfirmation(false)}
onConfirm={handleConfirm} onConfirm={handleConfirm}
cancelButtonChildren={formatMessage('deleteRoleButton.cancel')} cancelButtonChildren={formatMessage('deleteRoleButton.cancel')}
confirmButtonChildren={formatMessage('deleteRoleButton.confirm')} confirmButtonChildren={formatMessage('deleteRoleButton.confirm')}
data-test="delete-role-modal" data-test="delete-role-modal"
errorMessage={roleErrorMessage || generalErrorMessage}
/> />
</> </>
); );

View File

@@ -3,7 +3,6 @@ import DeleteIcon from '@mui/icons-material/Delete';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { getGeneralErrorMessage } from 'helpers/errors';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import * as React from 'react'; import * as React from 'react';
import ConfirmationDialog from 'components/ConfirmationDialog'; import ConfirmationDialog from 'components/ConfirmationDialog';
@@ -13,21 +12,12 @@ import useAdminUserDelete from 'hooks/useAdminUserDelete';
function DeleteUserButton(props) { function DeleteUserButton(props) {
const { userId } = props; const { userId } = props;
const [showConfirmation, setShowConfirmation] = React.useState(false); const [showConfirmation, setShowConfirmation] = React.useState(false);
const { const { mutateAsync: deleteUser } = useAdminUserDelete(userId);
mutateAsync: deleteUser,
error: deleteUserError,
reset: resetDeleteUser,
} = useAdminUserDelete(userId);
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const enqueueSnackbar = useEnqueueSnackbar(); const enqueueSnackbar = useEnqueueSnackbar();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const generalErrorMessage = getGeneralErrorMessage({
error: deleteUserError,
fallbackMessage: formatMessage('deleteUserButton.deleteError'),
});
const handleConfirm = React.useCallback(async () => { const handleConfirm = React.useCallback(async () => {
try { try {
await deleteUser(); await deleteUser();
@@ -39,14 +29,16 @@ function DeleteUserButton(props) {
'data-test': 'snackbar-delete-user-success', 'data-test': 'snackbar-delete-user-success',
}, },
}); });
} catch {} } catch (error) {
enqueueSnackbar(
error?.message || formatMessage('deleteUserButton.deleteError'),
{
variant: 'error',
},
);
}
}, [deleteUser]); }, [deleteUser]);
const handleClose = () => {
setShowConfirmation(false);
resetDeleteUser();
};
return ( return (
<> <>
<IconButton <IconButton
@@ -61,12 +53,11 @@ function DeleteUserButton(props) {
open={showConfirmation} open={showConfirmation}
title={formatMessage('deleteUserButton.title')} title={formatMessage('deleteUserButton.title')}
description={formatMessage('deleteUserButton.description')} description={formatMessage('deleteUserButton.description')}
onClose={handleClose} onClose={() => setShowConfirmation(false)}
onConfirm={handleConfirm} onConfirm={handleConfirm}
cancelButtonChildren={formatMessage('deleteUserButton.cancel')} cancelButtonChildren={formatMessage('deleteUserButton.cancel')}
confirmButtonChildren={formatMessage('deleteUserButton.confirm')} confirmButtonChildren={formatMessage('deleteUserButton.confirm')}
data-test="delete-user-modal" data-test="delete-user-modal"
errorMessage={generalErrorMessage}
/> />
</> </>
); );

View File

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

View File

@@ -2,7 +2,6 @@ import * as React from 'react';
import { useNavigate, Link as RouterLink } from 'react-router-dom'; import { useNavigate, Link as RouterLink } from 'react-router-dom';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Link from '@mui/material/Link'; import Link from '@mui/material/Link';
import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import LoadingButton from '@mui/lab/LoadingButton'; import LoadingButton from '@mui/lab/LoadingButton';
import useAuthentication from 'hooks/useAuthentication'; import useAuthentication from 'hooks/useAuthentication';
@@ -12,6 +11,7 @@ import Form from 'components/Form';
import TextField from 'components/TextField'; import TextField from 'components/TextField';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import useCreateAccessToken from 'hooks/useCreateAccessToken'; import useCreateAccessToken from 'hooks/useCreateAccessToken';
import { Alert } from '@mui/material';
function LoginForm() { function LoginForm() {
const isCloud = useCloud(); const isCloud = useCloud();
@@ -45,7 +45,7 @@ function LoginForm() {
const renderError = () => { const renderError = () => {
const errors = error?.response?.data?.errors?.general || [ const errors = error?.response?.data?.errors?.general || [
error?.message || formatMessage('loginForm.error'), formatMessage('loginForm.error'),
]; ];
return errors.map((error) => ( return errors.map((error) => (

View File

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

View File

@@ -1,29 +0,0 @@
// Helpers to extract errors received from the API
export const getGeneralErrorMessage = ({ error, fallbackMessage }) => {
if (!error) {
return;
}
const errors = error?.response?.data?.errors;
const generalError = errors?.general;
if (generalError && Array.isArray(generalError)) {
return generalError.join(' ');
}
if (!errors) {
return error?.message || fallbackMessage;
}
};
export const getFieldErrorMessage = ({ fieldName, error }) => {
const errors = error?.response?.data?.errors;
const fieldErrors = errors?.[fieldName];
if (fieldErrors && Array.isArray(fieldErrors)) {
return fieldErrors.join(', ');
}
return '';
};

View File

@@ -245,7 +245,6 @@
"deleteRoleButton.cancel": "Cancel", "deleteRoleButton.cancel": "Cancel",
"deleteRoleButton.confirm": "Delete", "deleteRoleButton.confirm": "Delete",
"deleteRoleButton.successfullyDeleted": "The role has been deleted.", "deleteRoleButton.successfullyDeleted": "The role has been deleted.",
"deleteRoleButton.generalError": "Failed while deleting!",
"editRolePage.title": "Edit role", "editRolePage.title": "Edit role",
"createRolePage.title": "Create role", "createRolePage.title": "Create role",
"roleForm.name": "Name", "roleForm.name": "Name",

View File

@@ -66,8 +66,8 @@ function RoleMappings({ provider, providerLoading }) {
const enqueueSnackbar = useEnqueueSnackbar(); const enqueueSnackbar = useEnqueueSnackbar();
const { const {
mutateAsync: updateRoleMappings, mutateAsync: updateSamlAuthProvidersRoleMappings,
isPending: isUpdateRoleMappingsPending, isPending: isUpdateSamlAuthProvidersRoleMappingsPending,
} = useAdminUpdateSamlAuthProviderRoleMappings(provider?.id); } = useAdminUpdateSamlAuthProviderRoleMappings(provider?.id);
const { data, isLoading: isAdminSamlAuthProviderRoleMappingsLoading } = const { data, isLoading: isAdminSamlAuthProviderRoleMappingsLoading } =
@@ -79,7 +79,7 @@ function RoleMappings({ provider, providerLoading }) {
const handleRoleMappingsUpdate = async (values) => { const handleRoleMappingsUpdate = async (values) => {
try { try {
if (provider?.id) { if (provider?.id) {
await updateRoleMappings( await updateSamlAuthProvidersRoleMappings(
values.roleMappings.map(({ roleId, remoteRoleName }) => ({ values.roleMappings.map(({ roleId, remoteRoleName }) => ({
roleId, roleId,
remoteRoleName, remoteRoleName,
@@ -148,7 +148,7 @@ function RoleMappings({ provider, providerLoading }) {
variant="contained" variant="contained"
color="primary" color="primary"
sx={{ boxShadow: 2 }} sx={{ boxShadow: 2 }}
loading={isUpdateRoleMappingsPending} loading={isUpdateSamlAuthProvidersRoleMappingsPending}
> >
{formatMessage('roleMappingsForm.save')} {formatMessage('roleMappingsForm.save')}
</LoadingButton> </LoadingButton>