From 340f07125db51e246da7f9a2f191b8876bc78e21 Mon Sep 17 00:00:00 2001 From: Catalin Oancea Date: Tue, 24 Dec 2024 20:30:19 +0100 Subject: [PATCH 1/3] Delete endpoint for custom projects --- .../custom-projects.controller.ts | 27 ++++++ .../custom-projects.service.ts | 12 +++ .../custom-projects-delete.spec.ts | 87 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 api/test/integration/custom-projects/custom-projects-delete.spec.ts diff --git a/api/src/modules/custom-projects/custom-projects.controller.ts b/api/src/modules/custom-projects/custom-projects.controller.ts index a00ce809..72f96eba 100644 --- a/api/src/modules/custom-projects/custom-projects.controller.ts +++ b/api/src/modules/custom-projects/custom-projects.controller.ts @@ -144,4 +144,31 @@ export class CustomProjectsController { }, ); } + + @UseGuards(AuthGuard('jwt'), RolesGuard) + @RequiredRoles(ROLES.PARTNER, ROLES.ADMIN) + @TsRestHandler(customProjectContract.deleteCustomProjects) + async deleteCustomProjects( + @GetUser() user: User, + @Body() body: { ids: string[] }, + ): Promise { + return tsRestHandler( + customProjectContract.deleteCustomProjects, + async () => { + if ( + !(await this.customProjects.canUserDeleteProjects(user.id, body.ids)) + ) { + return { + status: 401, + body: null, + }; + } + await this.customProjects.removeMany(body.ids); + return { + status: 200, + body: null, + }; + }, + ); + } } diff --git a/api/src/modules/custom-projects/custom-projects.service.ts b/api/src/modules/custom-projects/custom-projects.service.ts index e900a875..49908f05 100644 --- a/api/src/modules/custom-projects/custom-projects.service.ts +++ b/api/src/modules/custom-projects/custom-projects.service.ts @@ -22,6 +22,7 @@ import { FetchSpecification } from 'nestjs-base-service'; import { GetActivityTypesDefaults } from '@shared/dtos/custom-projects/get-activity-types-defaults.dto'; import { z } from 'zod'; import { customProjecsQuerySchema } from '@shared/contracts/custom-projects.contract'; +import { In } from 'typeorm'; export type CustomProjectFetchSpecificacion = z.infer< typeof customProjecsQuerySchema @@ -143,4 +144,15 @@ export class CustomProjectsService extends AppBaseService< return query; } + + async canUserDeleteProjects( + userId: string, + projectIds: string[], + ): Promise { + const customProjects = await this.repo.findBy({ id: In(projectIds) }); + + return customProjects.every( + (customProject) => customProject.user?.id === userId, + ); + } } diff --git a/api/test/integration/custom-projects/custom-projects-delete.spec.ts b/api/test/integration/custom-projects/custom-projects-delete.spec.ts new file mode 100644 index 00000000..e84e0e1e --- /dev/null +++ b/api/test/integration/custom-projects/custom-projects-delete.spec.ts @@ -0,0 +1,87 @@ +import { TestManager } from '../../utils/test-manager'; +import { customProjectContract } from '@shared/contracts/custom-projects.contract'; +import { User } from '@shared/entities/users/user.entity'; +import { HttpStatus } from '@nestjs/common'; + +describe('Delete Custom projects', () => { + let testManager: TestManager; + let jwtToken: string; + let user: User; + + beforeAll(async () => { + testManager = await TestManager.createTestManager(); + }); + + beforeEach(async () => { + ({ jwtToken, user } = await testManager.setUpTestUser()); + await testManager.ingestCountries(); + await testManager.ingestExcel(jwtToken); + }); + + afterEach(async () => { + await testManager.clearDatabase(); + }); + + afterAll(async () => { + await testManager.close(); + }); + + describe('Delete Custom Projects', () => { + test('An anonymous user should be UNAUTHORIZED to delete a custom project', async () => { + // Given + const customProject = await testManager.mocks().createCustomProject({ + user: { id: user.id } as User, + }); + + // When deleting the custom project + const response = await testManager + .request() + .delete(customProjectContract.deleteCustomProjects.path) + .send({ ids: [customProject.id] }); + + // Then + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + expect(response.body.errors).toBeDefined(); + }); + + test.only('An authenticated user should not be able to delete projects that do not belong to them', async () => { + // Given a custom project exists + const customProject = await testManager.mocks().createCustomProject(); + + // When deleting the custom project + const response = await testManager + .request() + .delete(customProjectContract.deleteCustomProjects.path) + .set('Authorization', `Bearer ${jwtToken}`) + .send({ ids: [customProject.id] }); + + // Then + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + + test('An authenticated user should be able to delete projects', async () => { + // Given a custom project exists + const customProject = await testManager.mocks().createCustomProject({ + user: { id: user.id } as User, + }); + + // When deleting the custom project + const response = await testManager + .request() + .delete(customProjectContract.deleteCustomProjects.path) + .set('Authorization', `Bearer ${jwtToken}`) + .send({ ids: [customProject.id] }); + + expect(response.status).toBe(HttpStatus.OK); + + // Then the project should no longer exist + const getProjectResponse = await testManager + .request() + .get( + `${customProjectContract.getCustomProject.path}/${customProject.id}`, + ) + .set('Authorization', `Bearer ${jwtToken}`); + expect(getProjectResponse.status).toBe(HttpStatus.NOT_FOUND); + }); + }); +}); From 7f84c872cf658ada6d0c1c9c86e838b546e39baf Mon Sep 17 00:00:00 2001 From: Catalin Oancea Date: Tue, 24 Dec 2024 20:33:37 +0100 Subject: [PATCH 2/3] Delete endpoint for custom projects --- backoffice/index.ts | 4 ++-- shared/contracts/custom-projects.contract.ts | 6 +++--- shared/entities/custom-project.entity.ts | 15 +++++++++------ 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/backoffice/index.ts b/backoffice/index.ts index 176fe8e0..2b58ca85 100644 --- a/backoffice/index.ts +++ b/backoffice/index.ts @@ -210,14 +210,14 @@ const start = async () => { secure: false, maxAge: undefined, }, - }, + } ); app.use(admin.options.rootPath, adminRouter); app.listen(PORT, () => { console.log( - `AdminJS started on http://localhost:${PORT}${admin.options.rootPath}`, + `AdminJS started on http://localhost:${PORT}${admin.options.rootPath}` ); }); }; diff --git a/shared/contracts/custom-projects.contract.ts b/shared/contracts/custom-projects.contract.ts index b01d1b8e..60a1c66d 100644 --- a/shared/contracts/custom-projects.contract.ts +++ b/shared/contracts/custom-projects.contract.ts @@ -96,13 +96,13 @@ export const customProjectContract = contract.router({ }, body: contract.type(), }, - deleteCustomProject: { + deleteCustomProjects: { method: "DELETE", - path: "/custom-projects/:id", + path: "/custom-projects", responses: { 200: contract.type(), }, - body: null, + body: contract.type<{ ids: string[] }>(), }, }); diff --git a/shared/entities/custom-project.entity.ts b/shared/entities/custom-project.entity.ts index 97d68510..226d3ec8 100644 --- a/shared/entities/custom-project.entity.ts +++ b/shared/entities/custom-project.entity.ts @@ -22,13 +22,13 @@ export enum CARBON_REVENUES_TO_COVER { } export enum PROJECT_SPECIFIC_EMISSION { - ONE_EMISSION_FACTOR = 'One emission factor', - TWO_EMISSION_FACTORS = 'Two emission factors', + ONE_EMISSION_FACTOR = "One emission factor", + TWO_EMISSION_FACTORS = "Two emission factors", } export enum PROJECT_EMISSION_FACTORS { - TIER_1 = 'Tier 1 - Global emission factor', - TIER_2 = 'Tier 2 - Country-specific emission factor', - TIER_3 = 'Tier 3 - Project specific emission factor', + TIER_1 = "Tier 1 - Global emission factor", + TIER_2 = "Tier 2 - Country-specific emission factor", + TIER_3 = "Tier 3 - Project specific emission factor", } @Entity({ name: "custom_projects" }) @@ -54,7 +54,10 @@ export class CustomProject { @Column({ name: "abatement_potential", type: "decimal", nullable: true }) abatementPotential?: number; - @ManyToOne(() => User, (user) => user.customProjects, { onDelete: "CASCADE" }) + @ManyToOne(() => User, (user) => user.customProjects, { + onDelete: "CASCADE", + eager: true, + }) @JoinColumn({ name: "user_id" }) user?: User; From b35c4c16a458d3f7d4b6b832fe955caa43252d46 Mon Sep 17 00:00:00 2001 From: Catalin Oancea Date: Tue, 24 Dec 2024 21:01:44 +0100 Subject: [PATCH 3/3] Move client to new endpoint --- client/src/containers/my-projects/columns.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/client/src/containers/my-projects/columns.tsx b/client/src/containers/my-projects/columns.tsx index c4029779..290c1086 100644 --- a/client/src/containers/my-projects/columns.tsx +++ b/client/src/containers/my-projects/columns.tsx @@ -49,13 +49,11 @@ const ActionsDropdown = ({ async (id: string): Promise => { try { const { status } = - await client.customProjects.deleteCustomProject.mutation({ - params: { - id, - }, + await client.customProjects.deleteCustomProjects.mutation({ extraHeaders: { ...getAuthHeader(session?.accessToken as string), }, + body: { ids: [id] }, }); return status === 200;