Merge pull request #2059 from automatisch/aut-1229

feat: write and implement REST API endpoint to create role
This commit is contained in:
Ali BARIN
2024-09-05 17:35:19 +02:00
committed by GitHub
40 changed files with 286 additions and 104 deletions

View File

@@ -10,7 +10,7 @@ import process from 'process';
async function fetchAdminRole() {
const role = await Role.query()
.where({
key: 'admin',
name: 'Admin',
})
.limit(1)
.first();

View File

@@ -15,7 +15,7 @@ describe('POST /api/v1/admin/apps/:appKey/auth-clients', () => {
beforeEach(async () => {
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
adminRole = await createRole({ key: 'admin' });
adminRole = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: adminRole.id });
token = await createAuthTokenByUserId(currentUser.id);

View File

@@ -15,7 +15,7 @@ describe('POST /api/v1/admin/apps/:appKey/config', () => {
beforeEach(async () => {
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
adminRole = await createRole({ key: 'admin' });
adminRole = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: adminRole.id });
token = await createAuthTokenByUserId(currentUser.id);

View File

@@ -15,7 +15,7 @@ describe('GET /api/v1/admin/apps/:appKey/auth-clients/:appAuthClientId', () => {
beforeEach(async () => {
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
adminRole = await createRole({ key: 'admin' });
adminRole = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: adminRole.id });
currentAppAuthClient = await createAppAuthClient({

View File

@@ -14,7 +14,7 @@ describe('GET /api/v1/admin/apps/:appKey/auth-clients', () => {
beforeEach(async () => {
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
adminRole = await createRole({ key: 'admin' });
adminRole = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: adminRole.id });
token = await createAuthTokenByUserId(currentUser.id);

View File

@@ -17,7 +17,7 @@ describe('PATCH /api/v1/admin/apps/:appKey/auth-clients', () => {
beforeEach(async () => {
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
adminRole = await createRole({ key: 'admin' });
adminRole = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: adminRole.id });
token = await createAuthTokenByUserId(currentUser.id);

View File

@@ -15,7 +15,7 @@ describe('PATCH /api/v1/admin/apps/:appKey/config', () => {
beforeEach(async () => {
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
adminRole = await createRole({ key: 'admin' });
adminRole = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: adminRole.id });
token = await createAuthTokenByUserId(currentUser.id);

View File

@@ -14,7 +14,7 @@ describe('PATCH /api/v1/admin/config', () => {
beforeEach(async () => {
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
adminRole = await createRole({ key: 'admin' });
adminRole = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: adminRole.id });
token = await createAuthTokenByUserId(currentUser.id);

View File

@@ -11,7 +11,7 @@ describe('GET /api/v1/admin/permissions/catalog', () => {
let role, currentUser, token;
beforeEach(async () => {
role = await createRole({ key: 'admin' });
role = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: role.id });
token = await createAuthTokenByUserId(currentUser.id);

View File

@@ -0,0 +1,22 @@
import { renderObject } from '../../../../../helpers/renderer.js';
import Role from '../../../../../models/role.js';
export default async (request, response) => {
const roleData = roleParams(request);
const roleWithPermissions = await Role.query().insertGraphAndFetch(roleData, {
relate: ['permissions'],
});
renderObject(response, roleWithPermissions, { status: 201 });
};
const roleParams = (request) => {
const { name, description, permissions } = request.body;
return {
name,
description,
permissions,
};
};

View File

@@ -0,0 +1,109 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../../../../../app.js';
import Role from '../../../../../models/role.js';
import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js';
import { createRole } from '../../../../../../test/factories/role.js';
import { createUser } from '../../../../../../test/factories/user.js';
import createRoleMock from '../../../../../../test/mocks/rest/api/v1/admin/roles/create-role.ee.js';
import * as license from '../../../../../helpers/license.ee.js';
describe('POST /api/v1/admin/roles', () => {
let role, currentUser, token;
beforeEach(async () => {
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
role = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: role.id });
token = await createAuthTokenByUserId(currentUser.id);
});
it('should return the created role along with permissions', async () => {
const roleData = {
name: 'Viewer',
description: '',
permissions: [
{
action: 'read',
subject: 'Flow',
conditions: ['isCreator'],
},
],
};
const response = await request(app)
.post('/api/v1/admin/roles')
.set('Authorization', token)
.send(roleData)
.expect(201);
const createdRole = await Role.query()
.withGraphFetched({ permissions: true })
.findOne({ name: 'Viewer' })
.throwIfNotFound();
const expectedPayload = await createRoleMock(
{
...createdRole,
...roleData,
isAdmin: createdRole.isAdmin,
},
[
{
...createdRole.permissions[0],
...roleData.permissions[0],
},
]
);
expect(response.body).toEqual(expectedPayload);
});
it('should return unprocessable entity response for invalid role data', async () => {
const roleData = {
description: '',
permissions: [],
};
const response = await request(app)
.post('/api/v1/admin/roles')
.set('Authorization', token)
.send(roleData)
.expect(422);
expect(response.body).toStrictEqual({
errors: {
name: ["must have required property 'name'"],
},
meta: {
type: 'ModelValidation',
},
});
});
it('should return unprocessable entity response for duplicate role', async () => {
await createRole({ name: 'Viewer' });
const roleData = {
name: 'Viewer',
permissions: [],
};
const response = await request(app)
.post('/api/v1/admin/roles')
.set('Authorization', token)
.send(roleData)
.expect(422);
expect(response.body).toStrictEqual({
errors: {
name: ["'name' must be unique."],
},
meta: {
type: 'UniqueViolationError',
},
});
});
});

View File

@@ -13,7 +13,7 @@ describe('GET /api/v1/admin/roles/:roleId', () => {
let role, currentUser, token, permissionOne, permissionTwo;
beforeEach(async () => {
role = await createRole({ key: 'admin' });
role = await createRole({ name: 'Admin' });
permissionOne = await createPermission({ roleId: role.id });
permissionTwo = await createPermission({ roleId: role.id });
currentUser = await createUser({ roleId: role.id });

View File

@@ -11,8 +11,8 @@ describe('GET /api/v1/admin/roles', () => {
let roleOne, roleTwo, currentUser, token;
beforeEach(async () => {
roleOne = await createRole({ key: 'admin' });
roleTwo = await createRole({ key: 'user' });
roleOne = await createRole({ name: 'Admin' });
roleTwo = await createRole({ name: 'User' });
currentUser = await createUser({ roleId: roleOne.id });
token = await createAuthTokenByUserId(currentUser.id);

View File

@@ -13,7 +13,7 @@ describe('POST /api/v1/admin/saml-auth-provider', () => {
beforeEach(async () => {
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
role = await createRole({ key: 'admin' });
role = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: role.id });
token = await createAuthTokenByUserId(currentUser.id);

View File

@@ -13,7 +13,7 @@ describe('GET /api/v1/admin/saml-auth-providers/:samlAuthProviderId/role-mapping
let roleMappingOne, roleMappingTwo, samlAuthProvider, currentUser, token;
beforeEach(async () => {
const role = await createRole({ key: 'admin' });
const role = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: role.id });
samlAuthProvider = await createSamlAuthProvider();

View File

@@ -13,7 +13,7 @@ describe('GET /api/v1/admin/saml-auth-provider/:samlAuthProviderId', () => {
let samlAuthProvider, currentUser, token;
beforeEach(async () => {
const role = await createRole({ key: 'admin' });
const role = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: role.id });
samlAuthProvider = await createSamlAuthProvider();

View File

@@ -12,7 +12,7 @@ describe('GET /api/v1/admin/saml-auth-providers', () => {
let samlAuthProviderOne, samlAuthProviderTwo, currentUser, token;
beforeEach(async () => {
const role = await createRole({ key: 'admin' });
const role = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: role.id });
samlAuthProviderOne = await createSamlAuthProvider();

View File

@@ -15,7 +15,7 @@ describe('PATCH /api/v1/admin/saml-auth-provider/:samlAuthProviderId', () => {
beforeEach(async () => {
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
role = await createRole({ key: 'admin' });
role = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: role.id });
token = await createAuthTokenByUserId(currentUser.id);

View File

@@ -10,7 +10,7 @@ describe('DELETE /api/v1/admin/users/:userId', () => {
let currentUser, currentUserRole, anotherUser, token;
beforeEach(async () => {
currentUserRole = await createRole({ key: 'admin' });
currentUserRole = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: currentUserRole.id });
anotherUser = await createUser();

View File

@@ -12,7 +12,7 @@ describe('GET /api/v1/admin/users/:userId', () => {
let currentUser, currentUserRole, anotherUser, anotherUserRole, token;
beforeEach(async () => {
currentUserRole = await createRole({ key: 'admin' });
currentUserRole = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: currentUserRole.id });
anotherUser = await createUser();

View File

@@ -10,7 +10,7 @@ describe('GET /api/v1/admin/users', () => {
let currentUser, currentUserRole, anotherUser, anotherUserRole, token;
beforeEach(async () => {
currentUserRole = await createRole({ key: 'admin' });
currentUserRole = await createRole({ name: 'Admin' });
currentUser = await createUser({
roleId: currentUserRole.id,
@@ -18,7 +18,6 @@ describe('GET /api/v1/admin/users', () => {
});
anotherUserRole = await createRole({
key: 'anotherUser',
name: 'Another user role',
});

View File

@@ -11,7 +11,7 @@ describe('PATCH /api/v1/admin/users/:userId', () => {
let currentUser, adminRole, token;
beforeEach(async () => {
adminRole = await createRole({ key: 'admin' });
adminRole = await createRole({ name: 'Admin' });
currentUser = await createUser({ roleId: adminRole.id });
token = await createAuthTokenByUserId(currentUser.id);

View File

@@ -13,8 +13,7 @@ describe('POST /api/v1/installation/users', () => {
beforeEach(async () => {
adminRole = await createRole({
name: 'Admin',
key: 'admin',
})
});
});
describe('for incomplete installations', () => {
@@ -26,7 +25,7 @@ describe('POST /api/v1/installation/users', () => {
.send({
email: 'user@automatisch.io',
password: 'password',
fullName: 'Initial admin'
fullName: 'Initial admin',
})
.expect(204);
@@ -48,7 +47,7 @@ describe('POST /api/v1/installation/users', () => {
.send({
email: 'user@automatisch.io',
password: 'password',
fullName: 'Initial admin'
fullName: 'Initial admin',
})
.expect(403);
@@ -71,7 +70,7 @@ describe('POST /api/v1/installation/users', () => {
.send({
email: 'user@automatisch.io',
password: 'password',
fullName: 'Initial admin'
fullName: 'Initial admin',
})
.expect(403);
@@ -80,5 +79,5 @@ describe('POST /api/v1/installation/users', () => {
expect(user).toBeUndefined();
expect(await Config.isInstallationCompleted()).toBe(true);
});
})
});
});

View File

@@ -0,0 +1,11 @@
export async function up(knex) {
return await knex.schema.alterTable('roles', (table) => {
table.unique('name');
});
}
export async function down(knex) {
return await knex.schema.alterTable('roles', function (table) {
table.dropUnique('name');
});
}

View File

@@ -0,0 +1,19 @@
export async function up(knex) {
return await knex.schema.alterTable('roles', (table) => {
table.dropColumn('key');
});
}
export async function down(knex) {
await knex.schema.alterTable('roles', (table) => {
table.string('key');
});
await knex('roles').update({
key: knex.raw('LOWER(??)', ['name']),
});
return await knex.schema.alterTable('roles', (table) => {
table.string('key').notNullable().alter();
});
}

View File

@@ -1,5 +1,4 @@
import createConnection from './mutations/create-connection.js';
import createRole from './mutations/create-role.ee.js';
import createStep from './mutations/create-step.js';
import createUser from './mutations/create-user.ee.js';
import deleteFlow from './mutations/delete-flow.js';
@@ -26,7 +25,6 @@ import deleteCurrentUser from './mutations/delete-current-user.ee.js';
const mutationResolvers = {
createConnection,
createFlow,
createRole,
createStep,
createUser,
deleteCurrentUser,

View File

@@ -1,29 +0,0 @@
import kebabCase from 'lodash/kebabCase.js';
import Role from '../../models/role.js';
const createRole = async (_parent, params, context) => {
context.currentUser.can('create', 'Role');
const { name, description, permissions } = params.input;
const key = kebabCase(name);
const existingRole = await Role.query().findOne({ key });
if (existingRole) {
throw new Error('Role already exists!');
}
return await Role.query()
.insertGraph(
{
key,
name,
description,
permissions,
},
{ relate: ['permissions'] }
)
.returning('*');
};
export default createRole;

View File

@@ -32,7 +32,7 @@ const createUser = async (_parent, params, context) => {
userPayload.roleId = params.input.role.id;
} catch {
// void
const role = await Role.query().findOne({ key: 'admin' });
const role = await Role.findAdmin();
userPayload.roleId = role.id;
}

View File

@@ -15,7 +15,7 @@ const registerUser = async (_parent, params) => {
throw new Error('User already exists!');
}
const role = await Role.query().findOne({ key: 'user' });
const role = await Role.query().findOne({ name: 'User' });
const user = await User.query().insert({
fullName,

View File

@@ -4,7 +4,6 @@ type Query {
type Mutation {
createConnection(input: CreateConnectionInput): Connection
createFlow(input: CreateFlowInput): Flow
createRole(input: CreateRoleInput): Role
createStep(input: CreateStepInput): Step
createUser(input: CreateUserInput): UserWithAcceptInvitationUrl
deleteCurrentUser: Boolean
@@ -342,12 +341,6 @@ input PermissionInput {
conditions: [String]
}
input CreateRoleInput {
name: String!
description: String
permissions: [PermissionInput]
}
input UpdateRoleInput {
id: String!
name: String!

View File

@@ -7,22 +7,17 @@ class Role extends Base {
static jsonSchema = {
type: 'object',
required: ['name', 'key'],
required: ['name'],
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1 },
key: { type: 'string', minLength: 1 },
description: { type: ['string', 'null'], maxLength: 255 },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
};
static get virtualAttributes() {
return ['isAdmin'];
}
static relationMappings = () => ({
users: {
relation: Base.HasManyRelation,
@@ -42,12 +37,16 @@ class Role extends Base {
},
});
static get virtualAttributes() {
return ['isAdmin'];
}
get isAdmin() {
return this.key === 'admin';
return this.name === 'Admin';
}
static async findAdmin() {
return await this.query().findOne({ key: 'admin' });
return await this.query().findOne({ name: 'Admin' });
}
}

View File

@@ -2,11 +2,20 @@ import { Router } from 'express';
import { authenticateUser } from '../../../../helpers/authentication.js';
import { authorizeAdmin } from '../../../../helpers/authorization.js';
import { checkIsEnterprise } from '../../../../helpers/check-is-enterprise.js';
import createRoleAction from '../../../../controllers/api/v1/admin/roles/create-role.ee.js';
import getRolesAction from '../../../../controllers/api/v1/admin/roles/get-roles.ee.js';
import getRoleAction from '../../../../controllers/api/v1/admin/roles/get-role.ee.js';
const router = Router();
router.post(
'/',
authenticateUser,
authorizeAdmin,
checkIsEnterprise,
createRoleAction
);
router.get(
'/',
authenticateUser,

View File

@@ -1,8 +1,10 @@
import { faker } from '@faker-js/faker';
import Role from '../../src/models/role';
export const createRole = async (params = {}) => {
params.name = params?.name || 'Viewer';
params.key = params?.key || 'viewer';
const name = faker.lorem.word();
params.name = params?.name || name;
const role = await Role.query().insertAndFetch(params);

View File

@@ -0,0 +1,32 @@
const createRoleMock = async (role, permissions = []) => {
const data = {
id: role.id,
name: role.name,
isAdmin: role.isAdmin,
description: role.description,
createdAt: role.createdAt.getTime(),
updatedAt: role.updatedAt.getTime(),
permissions: permissions.map((permission) => ({
id: permission.id,
action: permission.action,
conditions: permission.conditions,
roleId: permission.roleId,
subject: permission.subject,
createdAt: permission.createdAt.getTime(),
updatedAt: permission.updatedAt.getTime(),
})),
};
return {
data: data,
meta: {
count: 1,
currentPage: null,
isArray: false,
totalPages: null,
type: 'Role',
},
};
};
export default createRoleMock;

View File

@@ -1,7 +1,6 @@
const getRoleMock = async (role, permissions) => {
const data = {
id: role.id,
key: role.key,
name: role.name,
isAdmin: role.isAdmin,
description: role.description,

View File

@@ -2,7 +2,6 @@ const getRolesMock = async (roles) => {
const data = roles.map((role) => {
return {
id: role.id,
key: role.key,
name: role.name,
isAdmin: role.isAdmin,
description: role.description,

View File

@@ -9,7 +9,6 @@ const updateUserMock = (user, role) => {
updatedAt: user.updatedAt.getTime(),
role: {
id: role.id,
key: role.key,
name: role.name,
isAdmin: role.isAdmin,
createdAt: role.createdAt.getTime(),

View File

@@ -1,11 +0,0 @@
import { gql } from '@apollo/client';
export const CREATE_ROLE = gql`
mutation CreateRole($input: CreateRoleInput) {
createRole(input: $input) {
id
key
name
description
}
}
`;

View File

@@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useAdminCreateRole() {
const queryClient = useQueryClient();
const query = useMutation({
mutationFn: async (payload) => {
const { data } = await api.post('/v1/admin/roles', payload);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['apps', 'roles'],
});
},
});
return query;
}

View File

@@ -1,4 +1,3 @@
import { useMutation } from '@apollo/client';
import LoadingButton from '@mui/lab/LoadingButton';
import Grid from '@mui/material/Grid';
import Stack from '@mui/material/Stack';
@@ -6,42 +5,55 @@ import PermissionCatalogField from 'components/PermissionCatalogField/index.ee';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import Container from 'components/Container';
import Form from 'components/Form';
import PageTitle from 'components/PageTitle';
import TextField from 'components/TextField';
import * as URLS from 'config/urls';
import { CREATE_ROLE } from 'graphql/mutations/create-role.ee';
import { getPermissions } from 'helpers/computePermissions.ee';
import useFormatMessage from 'hooks/useFormatMessage';
import useAdminCreateRole from 'hooks/useAdminCreateRole';
export default function CreateRole() {
const navigate = useNavigate();
const formatMessage = useFormatMessage();
const [createRole, { loading }] = useMutation(CREATE_ROLE);
const enqueueSnackbar = useEnqueueSnackbar();
const { mutateAsync: createRole, isPending: isCreateRolePending } =
useAdminCreateRole();
const handleRoleCreation = async (roleData) => {
try {
const permissions = getPermissions(roleData.computedPermissions);
await createRole({
variables: {
input: {
name: roleData.name,
description: roleData.description,
permissions,
},
},
name: roleData.name,
description: roleData.description,
permissions,
});
enqueueSnackbar(formatMessage('createRole.successfullyCreated'), {
variant: 'success',
SnackbarProps: {
'data-test': 'snackbar-create-role-success',
},
});
navigate(URLS.ROLES);
} catch (error) {
throw new Error('Failed while creating!');
const errors = Object.values(error.response.data.errors);
for (const [errorMessage] of errors) {
enqueueSnackbar(errorMessage, {
variant: 'error',
SnackbarProps: {
'data-test': 'snackbar-error',
},
});
}
}
};
return (
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
<Grid container item xs={12} sm={10} md={9}>
@@ -79,7 +91,7 @@ export default function CreateRole() {
variant="contained"
color="primary"
sx={{ boxShadow: 2 }}
loading={loading}
loading={isCreateRolePending}
data-test="create-button"
>
{formatMessage('createRole.submit')}