From 5a5ce6e273673863e2e4a72988fb6c89b1bdc0bf Mon Sep 17 00:00:00 2001 From: Marc Littlemore Date: Thu, 19 Dec 2024 14:37:46 +0000 Subject: [PATCH 1/6] feat(API): Allow deletion of users from projects --- .../cli/src/controllers/project.controller.ts | 23 ++++++---- .../v1/handlers/projects/projects.handler.ts | 23 ++++++++++ .../paths/projects.projectId.users.id.yml | 44 +++++++++++++++++++ packages/cli/src/public-api/v1/openapi.yml | 2 + packages/cli/src/requests.ts | 1 + 5 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index 415e0e9519130..41ba02364e34f 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -196,14 +196,7 @@ export class ProjectController { await this.projectsService.updateProject(req.body.name, req.params.projectId); } if (req.body.relations) { - try { - await this.projectsService.syncProjectRelations(req.params.projectId, req.body.relations); - } catch (e) { - if (e instanceof UnlicensedProjectRoleError) { - throw new BadRequestError(e.message); - } - throw e; - } + await this.syncProjectRelations(req.params.projectId, req.body.relations); this.eventService.emit('team-project-updated', { userId: req.user.id, @@ -214,6 +207,20 @@ export class ProjectController { } } + async syncProjectRelations( + projectId: string, + relations: ProjectRequest.ProjectRelationPayload[], + ) { + try { + await this.projectsService.syncProjectRelations(projectId, relations); + } catch (e) { + if (e instanceof UnlicensedProjectRoleError) { + throw new BadRequestError(e.message); + } + throw e; + } + } + @Delete('/:projectId') @ProjectScope('project:delete') async deleteProject(req: ProjectRequest.Delete) { diff --git a/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts b/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts index ea7220f7880d4..303949a615a84 100644 --- a/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts @@ -12,6 +12,7 @@ import { encodeNextCursor } from '../../shared/services/pagination.service'; type Create = ProjectRequest.Create; type Update = ProjectRequest.Update; type Delete = ProjectRequest.Delete; +type DeleteUser = ProjectRequest.DeleteUser; type GetAll = PaginatedRequest; export = { @@ -64,4 +65,26 @@ export = { }); }, ], + deleteUserFromProject: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:update'), + async (req: DeleteUser, res: Response) => { + const { projectId, id: userId } = req.params; + + const project = await Container.get(ProjectRepository).findOne({ + where: { id: projectId }, + relations: { projectRelations: true }, + }); + + if (!project) { + return res.status(404).send({ message: 'Not found' }); + } + + const relations = project.projectRelations.filter((relation) => relation.userId !== userId); + + await Container.get(ProjectController).syncProjectRelations(projectId, relations); + + return res.status(204).send(); + }, + ], }; diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml new file mode 100644 index 0000000000000..d03fb5377b2c1 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml @@ -0,0 +1,44 @@ +delete: + x-eov-operation-id: deleteUserFromProject + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Projects + summary: Delete a user from a project + description: Delete a user from a project from your instance. + parameters: + - $ref: '../schemas/parameters/projectId.yml' + - $ref: '../../../users/spec/schemas/parameters/userIdentifier.yml' + responses: + '204': + description: Operation successful. + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' +# put: +# x-eov-operation-id: updateProject +# x-eov-operation-handler: v1/handlers/projects/projects.handler +# tags: +# - Project +# summary: Update a project +# description: Update a project. +# requestBody: +# description: Updated project object. +# content: +# application/json: +# schema: +# $ref: '../schemas/project.yml' +# required: true +# responses: +# '204': +# description: Operation successful. +# '400': +# $ref: '../../../../shared/spec/responses/badRequest.yml' +# '401': +# $ref: '../../../../shared/spec/responses/unauthorized.yml' +# '403': +# $ref: '../../../../shared/spec/responses/forbidden.yml' +# '404': +# $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 2608f349808f2..ec575fc556b37 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -82,6 +82,8 @@ paths: $ref: './handlers/projects/spec/paths/projects.yml' /projects/{projectId}: $ref: './handlers/projects/spec/paths/projects.projectId.yml' + /projects/{projectId}/users/{id}: + $ref: './handlers/projects/spec/paths/projects.projectId.users.id.yml' components: schemas: $ref: './shared/spec/schemas/_index.yml' diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 7afb1e1bd3c7b..14e12bed4288e 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -563,6 +563,7 @@ export declare namespace ProjectRequest { { name?: string; relations?: ProjectRelationPayload[] } >; type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>; + type DeleteUser = AuthenticatedRequest<{ projectId: string; id: string }, {}, {}, {}>; } // ---------------------------------- From 6dcbb7ade057488aae72e7588d28e297cb8efa24 Mon Sep 17 00:00:00 2001 From: Marc Littlemore Date: Fri, 20 Dec 2024 09:45:48 +0000 Subject: [PATCH 2/6] fix: tidy up schema references --- .../paths/projects.projectId.users.id.yml | 32 ++++--------------- .../spec/paths/projects.projectId.yml | 2 ++ 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml index d03fb5377b2c1..5fe78f062e505 100644 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml @@ -6,7 +6,12 @@ delete: summary: Delete a user from a project description: Delete a user from a project from your instance. parameters: - - $ref: '../schemas/parameters/projectId.yml' + - name: projectId + in: path + description: The ID of the project. + required: true + schema: + type: string - $ref: '../../../users/spec/schemas/parameters/userIdentifier.yml' responses: '204': @@ -17,28 +22,3 @@ delete: $ref: '../../../../shared/spec/responses/forbidden.yml' '404': $ref: '../../../../shared/spec/responses/notFound.yml' -# put: -# x-eov-operation-id: updateProject -# x-eov-operation-handler: v1/handlers/projects/projects.handler -# tags: -# - Project -# summary: Update a project -# description: Update a project. -# requestBody: -# description: Updated project object. -# content: -# application/json: -# schema: -# $ref: '../schemas/project.yml' -# required: true -# responses: -# '204': -# description: Operation successful. -# '400': -# $ref: '../../../../shared/spec/responses/badRequest.yml' -# '401': -# $ref: '../../../../shared/spec/responses/unauthorized.yml' -# '403': -# $ref: '../../../../shared/spec/responses/forbidden.yml' -# '404': -# $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml index a5aab19b3d6ed..8d084a40d9165 100644 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml @@ -23,6 +23,8 @@ put: - Project summary: Update a project description: Update a project. + parameters: + - $ref: '../schemas/parameters/projectId.yml' requestBody: description: Updated project object. content: From d471ed1ecb54fd22d7837917257fbf81619000e3 Mon Sep 17 00:00:00 2001 From: Marc Littlemore Date: Fri, 20 Dec 2024 10:38:07 +0000 Subject: [PATCH 3/6] fix: refactor schema to make test server work --- .../spec/paths/projects.projectId.users.id.yml | 2 +- .../projects/spec/paths/projects.projectId.yml | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml index 5fe78f062e505..d2bba47b95c09 100644 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.id.yml @@ -11,7 +11,7 @@ delete: description: The ID of the project. required: true schema: - type: string + type: string - $ref: '../../../users/spec/schemas/parameters/userIdentifier.yml' responses: '204': diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml index 8d084a40d9165..8ff8dc4b0f0db 100644 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml @@ -6,7 +6,12 @@ delete: summary: Delete a project description: Delete a project from your instance. parameters: - - $ref: '../schemas/parameters/projectId.yml' + - in: path + name: projectId + description: The ID of the project. + required: true + schema: + type: string responses: '204': description: Operation successful. @@ -24,7 +29,12 @@ put: summary: Update a project description: Update a project. parameters: - - $ref: '../schemas/parameters/projectId.yml' + - in: path + name: projectId + description: The ID of the project. + required: true + schema: + type: string requestBody: description: Updated project object. content: From 0cd58cb5c42f5e5d30dd881fdc6976cc61e92807 Mon Sep 17 00:00:00 2001 From: Marc Littlemore Date: Fri, 20 Dec 2024 11:42:02 +0000 Subject: [PATCH 4/6] test: add tests for deleting users from project --- .../integration/public-api/projects.test.ts | 126 +++++++++++++++++- .../test/integration/shared/db/projects.ts | 8 ++ 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index f815d9d07bf65..49af371101e06 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -1,8 +1,17 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { Telemetry } from '@/telemetry'; import { mockInstance } from '@test/mocking'; -import { createTeamProject, getProjectByNameOrFail } from '@test-integration/db/projects'; -import { createMemberWithApiKey, createOwnerWithApiKey } from '@test-integration/db/users'; +import { + createTeamProject, + getProjectByNameOrFail, + linkUserToProject, + getAllProjectRelations, +} from '@test-integration/db/projects'; +import { + createMemberWithApiKey, + createOwnerWithApiKey, + createMember, +} from '@test-integration/db/users'; import { setupTestServer } from '@test-integration/utils'; import * as testDb from '../shared/test-db'; @@ -393,4 +402,117 @@ describe('Projects in Public API', () => { expect(response.body).toHaveProperty('message', 'Forbidden'); }); }); + + describe('DELETE /projects/:id/users/:userId', () => { + it('if not authenticated, should reject with 401', async () => { + const project = await createTeamProject(); + const member = await createMember(); + + const response = await testServer + .publicApiAgentWithoutApiKey() + .delete(`/projects/${project.id}/users/${member.id}`); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject with a 403', async () => { + const owner = await createOwnerWithApiKey(); + const project = await createTeamProject(); + const member = await createMember(); + + const response = await testServer + .publicApiAgentFor(owner) + .delete(`/projects/${project.id}/users/${member.id}`); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject with 403', async () => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const member = await createMemberWithApiKey(); + const project = await createTeamProject(); + + const response = await testServer + .publicApiAgentFor(member) + .delete(`/projects/${project.id}/users/${member.id}`); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + describe('when user has correct license', () => { + beforeEach(() => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + }); + + it('should remove given user from project', async () => { + const owner = await createOwnerWithApiKey(); + const project = await createTeamProject('shared-project', owner); + const member = await createMember(); + await linkUserToProject(member, project, 'project:viewer'); + const projectBefore = await getAllProjectRelations({ + projectId: project.id, + }); + + const response = await testServer + .publicApiAgentFor(owner) + .delete(`/projects/${project.id}/users/${member.id}`); + + const projectAfter = await getAllProjectRelations({ + projectId: project.id, + }); + + expect(response.status).toBe(204); + expect(projectBefore.length).toEqual(2); + expect(projectBefore[0].userId).toEqual(owner.id); + expect(projectBefore[1].userId).toEqual(member.id); + + expect(projectAfter.length).toEqual(1); + expect(projectBefore[0].userId).toEqual(owner.id); + }); + + it('should reject with 404 if no project found', async () => { + const owner = await createOwnerWithApiKey(); + const member = await createMember(); + + const response = await testServer + .publicApiAgentFor(owner) + .delete(`/projects/123456/users/${member.id}`); + + expect(response.status).toBe(404); + expect(response.body).toHaveProperty('message', 'Not found'); + }); + + it('should remain unchanged if user if not in project', async () => { + const owner = await createOwnerWithApiKey(); + const project = await createTeamProject('shared-project', owner); + const member = await createMember(); + const projectBefore = await getAllProjectRelations({ + projectId: project.id, + }); + + const response = await testServer + .publicApiAgentFor(owner) + .delete(`/projects/${project.id}/users/${member.id}`); + + const projectAfter = await getAllProjectRelations({ + projectId: project.id, + }); + + expect(response.status).toBe(204); + expect(projectBefore.length).toEqual(1); + expect(projectBefore[0].userId).toEqual(owner.id); + + expect(projectAfter.length).toEqual(1); + expect(projectBefore[0].userId).toEqual(owner.id); + }); + }); + }); }); diff --git a/packages/cli/test/integration/shared/db/projects.ts b/packages/cli/test/integration/shared/db/projects.ts index 93310d5a99b8e..2fc8b1eb5792d 100644 --- a/packages/cli/test/integration/shared/db/projects.ts +++ b/packages/cli/test/integration/shared/db/projects.ts @@ -66,3 +66,11 @@ export const getProjectRelations = async ({ where: { projectId, userId, role }, }); }; + +export const getAllProjectRelations = async ({ + projectId, +}: Partial): Promise => { + return await Container.get(ProjectRelationRepository).find({ + where: { projectId }, + }); +}; From 73a4b9bcb15c989290b0a04742f2a99c02a7d55b Mon Sep 17 00:00:00 2001 From: Marc Littlemore Date: Fri, 20 Dec 2024 14:29:26 +0000 Subject: [PATCH 5/6] fix: move all project endpoints into same tag for docs --- .../v1/handlers/projects/spec/paths/projects.projectId.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml index 8ff8dc4b0f0db..145c5e85ec7ca 100644 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.yml @@ -25,7 +25,7 @@ put: x-eov-operation-id: updateProject x-eov-operation-handler: v1/handlers/projects/projects.handler tags: - - Project + - Projects summary: Update a project description: Update a project. parameters: From cdfc40dc2d93b7a91bca335e15b72254ffdbbf58 Mon Sep 17 00:00:00 2001 From: Marc Littlemore Date: Fri, 20 Dec 2024 16:42:55 +0000 Subject: [PATCH 6/6] feat: add ability to add users to projects via API --- .../v1/handlers/projects/projects.handler.ts | 40 +++++ .../spec/paths/projects.projectId.users.yml | 49 +++++ packages/cli/src/public-api/v1/openapi.yml | 2 + packages/cli/src/requests.ts | 5 + .../integration/public-api/projects.test.ts | 169 +++++++++++++++++- 5 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.yml diff --git a/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts b/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts index 303949a615a84..8ef1272da7093 100644 --- a/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts @@ -14,6 +14,7 @@ type Update = ProjectRequest.Update; type Delete = ProjectRequest.Delete; type DeleteUser = ProjectRequest.DeleteUser; type GetAll = PaginatedRequest; +type AddUsers = ProjectRequest.AddUsers; export = { createProject: [ @@ -87,4 +88,43 @@ export = { return res.status(204).send(); }, ], + addUsersToProject: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:update'), + async (req: AddUsers, res: Response) => { + const { projectId } = req.params; + const { users } = req.body; + + const project = await Container.get(ProjectRepository).findOne({ + where: { id: projectId }, + relations: { projectRelations: true }, + }); + + if (!project) { + return res.status(404).send({ message: 'Not found' }); + } + + const existingUsers = project.projectRelations.map((relation) => ({ + userId: relation.userId, + role: relation.role, + })); + + // TODO: + // - What happens when the user is already in the project? + // - What happens when the user is not found on the instance? + + try { + await Container.get(ProjectController).syncProjectRelations(projectId, [ + ...existingUsers, + ...users, + ]); + } catch (error) { + return res + .status(400) + .send({ message: error instanceof Error ? error.message : 'Bad request' }); + } + + return res.status(201).send(); + }, + ], }; diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.yml new file mode 100644 index 0000000000000..8114c829dd672 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.yml @@ -0,0 +1,49 @@ +post: + x-eov-operation-id: addUsersToProject + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Projects + summary: Add one or more users to a project + description: Add one or more users to a project from your instance. + parameters: + - name: projectId + in: path + description: The ID of the project. + required: true + schema: + type: string + requestBody: + description: Payload containing an array of one or more users to add to the project. + content: + application/json: + schema: + type: object + properties: + users: + type: array + description: A list of users and roles to add to the project. + items: + type: object + properties: + userId: + type: string + description: The unique identifier of the user. + example: '91765f0d-3b29-45df-adb9-35b23937eb92' + role: + type: string + description: The role assigned to the user in the project. + example: 'project:viewer' + required: + - userId + - role + required: + - users + responses: + '201': + description: Operation successful. + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index ec575fc556b37..3f646dc2fbb21 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -82,6 +82,8 @@ paths: $ref: './handlers/projects/spec/paths/projects.yml' /projects/{projectId}: $ref: './handlers/projects/spec/paths/projects.projectId.yml' + /projects/{projectId}/users: + $ref: './handlers/projects/spec/paths/projects.projectId.users.yml' /projects/{projectId}/users/{id}: $ref: './handlers/projects/spec/paths/projects.projectId.users.id.yml' components: diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 14e12bed4288e..3d27484fe79e4 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -564,6 +564,11 @@ export declare namespace ProjectRequest { >; type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>; type DeleteUser = AuthenticatedRequest<{ projectId: string; id: string }, {}, {}, {}>; + type AddUsers = AuthenticatedRequest< + { projectId: string }, + {}, + { users: ProjectRelationPayload[] } + >; } // ---------------------------------- diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index 49af371101e06..49c7ed541dc17 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -475,7 +475,7 @@ describe('Projects in Public API', () => { expect(projectBefore[1].userId).toEqual(member.id); expect(projectAfter.length).toEqual(1); - expect(projectBefore[0].userId).toEqual(owner.id); + expect(projectAfter[0].userId).toEqual(owner.id); }); it('should reject with 404 if no project found', async () => { @@ -511,7 +511,174 @@ describe('Projects in Public API', () => { expect(projectBefore[0].userId).toEqual(owner.id); expect(projectAfter.length).toEqual(1); + expect(projectAfter[0].userId).toEqual(owner.id); + }); + }); + }); + + describe('POST /projects/:id/users', () => { + it('if not authenticated, should reject with 401', async () => { + const project = await createTeamProject(); + + const response = await testServer + .publicApiAgentWithoutApiKey() + .post(`/projects/${project.id}/users`); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject with a 403', async () => { + const owner = await createOwnerWithApiKey(); + const project = await createTeamProject(); + const member = await createMember(); + + const payload = { + users: [ + { + userId: member.id, + role: 'project:viewer', + }, + ], + }; + + const response = await testServer + .publicApiAgentFor(owner) + .post(`/projects/${project.id}/users`) + .send(payload); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject with 403', async () => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const member = await createMemberWithApiKey(); + const project = await createTeamProject(); + + const payload = { + users: [ + { + userId: member.id, + role: 'project:viewer', + }, + ], + }; + + const response = await testServer + .publicApiAgentFor(member) + .post(`/projects/${project.id}/users`) + .send(payload); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + describe('when user has correct license', () => { + beforeEach(() => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + }); + + it('should reject with 404 if no project found', async () => { + const owner = await createOwnerWithApiKey(); + const member = await createMember(); + + const payload = { + users: [ + { + userId: member.id, + role: 'project:viewer', + }, + ], + }; + + const response = await testServer + .publicApiAgentFor(owner) + .post('/projects/123456/users/') + .send(payload); + + expect(response.status).toBe(404); + expect(response.body).toHaveProperty('message', 'Not found'); + }); + + it('should add expected users to project', async () => { + testServer.license.enable('feat:projectRole:viewer'); + testServer.license.enable('feat:projectRole:editor'); + const owner = await createOwnerWithApiKey(); + const project = await createTeamProject('shared-project', owner); + const member = await createMember(); + const member2 = await createMember(); + const projectBefore = await getAllProjectRelations({ + projectId: project.id, + }); + + const payload = { + users: [ + { + userId: member.id, + role: 'project:viewer', + }, + { + userId: member2.id, + role: 'project:editor', + }, + ], + }; + + const response = await testServer + .publicApiAgentFor(owner) + .post(`/projects/${project.id}/users`) + .send(payload); + + const projectAfter = await getAllProjectRelations({ + projectId: project.id, + }); + + expect(response.status).toBe(201); + expect(projectBefore.length).toEqual(1); expect(projectBefore[0].userId).toEqual(owner.id); + + expect(projectAfter.length).toEqual(3); + expect(projectAfter[0]).toEqual( + expect.objectContaining({ userId: owner.id, role: 'project:admin' }), + ); + expect(projectAfter[1]).toEqual( + expect.objectContaining({ userId: member.id, role: 'project:viewer' }), + ); + expect(projectAfter[2]).toEqual( + expect.objectContaining({ userId: member2.id, role: 'project:editor' }), + ); + }); + + it('should reject with 400 if license does not include user role', async () => { + const owner = await createOwnerWithApiKey(); + const project = await createTeamProject('shared-project', owner); + const member = await createMember(); + + const payload = { + users: [ + { + userId: member.id, + role: 'project:viewer', + }, + ], + }; + + const response = await testServer + .publicApiAgentFor(owner) + .post(`/projects/${project.id}/users`) + .send(payload); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty( + 'message', + 'Your instance is not licensed to use role "project:viewer".', + ); }); }); });