feat: As a user I want to delete my finding

This commit is contained in:
Marcel Haag 2022-12-08 12:03:53 +01:00 committed by Cel
parent 27a8e963e9
commit 6a625349c8
14 changed files with 244 additions and 8 deletions

View File

@ -20,6 +20,8 @@ import {Category} from '@shared/models/category.model';
import {PentestStatus} from '@shared/models/pentest-status.model'; import {PentestStatus} from '@shared/models/pentest-status.model';
import {FindingDialogService} from '@shared/modules/finding-dialog/service/finding-dialog.service'; import {FindingDialogService} from '@shared/modules/finding-dialog/service/finding-dialog.service';
import {FindingDialogServiceMock} from '@shared/modules/finding-dialog/service/finding-dialog.service.mock'; import {FindingDialogServiceMock} from '@shared/modules/finding-dialog/service/finding-dialog.service.mock';
import {DialogService} from '@shared/services/dialog-service/dialog.service';
import {DialogServiceMock} from '@shared/services/dialog-service/dialog.service.mock';
const DESIRED_PROJECT_STATE_SESSION: ProjectStateModel = { const DESIRED_PROJECT_STATE_SESSION: ProjectStateModel = {
selectedProject: { selectedProject: {
@ -77,6 +79,7 @@ describe('PentestFindingsComponent', () => {
], ],
providers: [ providers: [
{provide: NotificationService, useValue: new NotificationServiceMock()}, {provide: NotificationService, useValue: new NotificationServiceMock()},
{provide: DialogService, useClass: DialogServiceMock},
{provide: FindingDialogService, useClass: FindingDialogServiceMock}, {provide: FindingDialogService, useClass: FindingDialogServiceMock},
] ]
}) })

View File

@ -3,7 +3,7 @@ import {PentestService} from '@shared/services/pentest.service';
import {BehaviorSubject, Observable} from 'rxjs'; import {BehaviorSubject, Observable} from 'rxjs';
import {Pentest} from '@shared/models/pentest.model'; import {Pentest} from '@shared/models/pentest.model';
import {UntilDestroy, untilDestroyed} from '@ngneat/until-destroy'; import {UntilDestroy, untilDestroyed} from '@ngneat/until-destroy';
import {filter, mergeMap, tap} from 'rxjs/operators'; import {catchError, filter, mergeMap, switchMap, tap} from 'rxjs/operators';
import {NotificationService, PopupType} from '@shared/services/notification.service'; import {NotificationService, PopupType} from '@shared/services/notification.service';
import { import {
Finding, Finding,
@ -21,6 +21,7 @@ import {PentestStatus} from '@shared/models/pentest-status.model';
import {Store} from '@ngxs/store'; import {Store} from '@ngxs/store';
import {UpdatePentestFindings} from '@shared/stores/project-state/project-state.actions'; import {UpdatePentestFindings} from '@shared/stores/project-state/project-state.actions';
import {ProjectState} from '@shared/stores/project-state/project-state'; import {ProjectState} from '@shared/stores/project-state/project-state';
import {DialogService} from '@shared/services/dialog-service/dialog.service';
@UntilDestroy() @UntilDestroy()
@Component({ @Component({
@ -33,6 +34,7 @@ export class PentestFindingsComponent implements OnInit {
constructor(private readonly pentestService: PentestService, constructor(private readonly pentestService: PentestService,
private dataSourceBuilder: NbTreeGridDataSourceBuilder<FindingEntry>, private dataSourceBuilder: NbTreeGridDataSourceBuilder<FindingEntry>,
private notificationService: NotificationService, private notificationService: NotificationService,
private dialogService: DialogService,
private findingDialogService: FindingDialogService, private findingDialogService: FindingDialogService,
private store: Store) { private store: Store) {
this.dataSource = dataSourceBuilder.create(this.data, this.getters); this.dataSource = dataSourceBuilder.create(this.data, this.getters);
@ -180,7 +182,32 @@ export class PentestFindingsComponent implements OnInit {
} }
onClickDeleteFinding(findingEntry): void { onClickDeleteFinding(findingEntry): void {
console.info('Coming soon..', findingEntry.data.findingId); const message = {
title: 'finding.delete.title',
key: 'finding.delete.key',
data: {name: findingEntry.data.title},
};
this.dialogService.openConfirmDialog(
message
).onClose.pipe(
filter((confirm) => !!confirm),
switchMap(() => this.pentestService.deleteFindingByPentestAndFindingId(
this.pentestInfo$.getValue() ? this.pentestInfo$.getValue().id : '',
findingEntry.data.findingId)
),
catchError(() => {
this.notificationService.showPopup('finding.popup.delete.failed', PopupType.FAILURE);
return [];
}),
untilDestroyed(this)
).subscribe({
next: () => {
this.loadFindingsData();
this.notificationService.showPopup('finding.popup.delete.success', PopupType.SUCCESS);
}, error: error => {
console.error(error);
}
});
} }
isLoading(): Observable<boolean> { isLoading(): Observable<boolean> {

View File

@ -113,6 +113,15 @@ export class PentestService {
return this.http.patch<Finding>(`${this.apiBaseURL}/${findingId}/finding`, finding); return this.http.patch<Finding>(`${this.apiBaseURL}/${findingId}/finding`, finding);
} }
/**
* Delete Finding
* @param pentestId the id of the pentest
* @param findingId the id of the finding
*/
public deleteFindingByPentestAndFindingId(pentestId: string, findingId: string): Observable<string> {
return this.http.delete<string>(`${this.apiBaseURL}/${pentestId}/finding/${findingId}`);
}
/** /**
* Get Comments for Pentest Id * Get Comments for Pentest Id
* @param pentestId the id of the project * @param pentestId the id of the project

View File

@ -418,6 +418,42 @@
} }
}, },
"response": [] "response": []
},
{
"name": "deleteFinding",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICItdG1lbEV0ZHhGTnRSMW9aNXlRdE5jaFFpX0RVN2VNeV9YcU44aXY0S3hzIn0.eyJleHAiOjE2NzA0MTQ3ODYsImlhdCI6MTY3MDQxNDQ4NiwianRpIjoiM2FmOWU5M2MtY2YzNi00MjQwLTkzNWEtNDkxYTJkZTY2MWU4IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4ODg4L2F1dGgvcmVhbG1zL2M0cG9fcmVhbG1fbG9jYWwiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiMTBlMDZkN2EtOGRkMC00ZWNkLTg5NjMtMDU2YjQ1MDc5YzRmIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiYzRwb19sb2NhbCIsInNlc3Npb25fc3RhdGUiOiI5M2ExNTBlMC03ZWRkLTQxZTgtYWE4Yi0yZWY5YTgzOWU4NDciLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbIioiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImM0cG9fdXNlciIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJjNHBvX2xvY2FsIjp7InJvbGVzIjpbInVzZXIiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoicHJvZmlsZSBlbWFpbCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6InRlc3QgdXNlciIsInByZWZlcnJlZF91c2VybmFtZSI6InR0dCIsImdpdmVuX25hbWUiOiJ0ZXN0IiwiZmFtaWx5X25hbWUiOiJ1c2VyIn0.QjUkCInyCJ5Wsz4q56gfsLqERr6pYlGjwNw-VsKNJ_3Jp-8Dazq9UmDGN8AmAkQ0sp0b-FMm3jArKMBpr84gKd65trvQx_qHvXev5x2MWBG4_9v3C9MmjxWcAYRVmfRdURUOhfto-4YfRwMwNRsKJfwMIjfS5VT8bHJWipcCDzaidN8h_LLORbmmQZ2o0l4Jnv5qrrWzUcSTeEeBpHGOjes1-T0gOlDJa34Z9x_xrsTsybKAylrmX03mDSI-f2h5XqqtgnrxtddtHXHatfxB1BHWq-FILDsGf0UG47FEQjqapFvn9bFiNyq0GVrgdK42miEO7ywOtCOKpCfAUnMwdQ",
"type": "string"
},
{
"key": "undefined",
"type": "any"
}
]
},
"method": "DELETE",
"header": [],
"url": {
"raw": "http://localhost:8443/pentests/11601f51-bc17-47fd-847d-0c53df5405b5/finding/cb33fad4-7965-4654-a9f9-f007edaca35c",
"protocol": "http",
"host": [
"localhost"
],
"port": "8443",
"path": [
"pentests",
"11601f51-bc17-47fd-847d-0c53df5405b5",
"finding",
"cb33fad4-7965-4654-a9f9-f007edaca35c"
]
}
},
"response": []
} }
] ]
}, },

View File

@ -235,11 +235,33 @@ include::{snippets}/updateFindingById/http-response.adoc[]
include::{snippets}/updateFindingById/response-fields.adoc[] include::{snippets}/updateFindingById/response-fields.adoc[]
=== Delete finding
To delete a finding, call the PATCH request /pentests/+{pentestId}+/finding/+{findingId}+
==== Request example
include::{snippets}/deleteFindingByPentestAndFindingId/http-request.adoc[]
==== Request structure
include::{snippets}/deleteFindingByPentestAndFindingId/path-parameters.adoc[]
==== Response example
include::{snippets}/deleteFindingByPentestAndFindingId/http-response.adoc[]
==== Response structure
include::{snippets}/deleteFindingByPentestAndFindingId/response-fields.adoc[]
== Change History == Change History
|=== |===
|Date |Change |Date |Change
|2022-12-09 |2022-12-09
|Added DELETE endpoint for Finding
|2022-12-08
|Added GET and PATCH endpoint for Finding |Added GET and PATCH endpoint for Finding
|2022-12-02 |2022-12-02
|Added GET and POST endpoint for Findings |Added GET and POST endpoint for Findings

View File

@ -39,4 +39,5 @@ enum class Errorcode(val code: Int) {
PentestInsertionFailed(6007), PentestInsertionFailed(6007),
ProjectPentestInsertionFailed(6008), ProjectPentestInsertionFailed(6008),
FindingInsertionFailed(6009), FindingInsertionFailed(6009),
FindingDeletionFailed(6010),
} }

View File

@ -52,6 +52,12 @@ fun Finding.toFindingResponseBody(): ResponseBody {
) )
} }
fun Finding.toFindingDeleteResponseBody(): ResponseBody {
return mapOf(
"id" to id
)
}
/** /**
* Validates if a [FindingRequestBody] is valid * Validates if a [FindingRequestBody] is valid
* *

View File

@ -1,5 +1,6 @@
package com.securityc4po.api.finding package com.securityc4po.api.finding
import org.springframework.data.mongodb.repository.DeleteQuery
import org.springframework.data.mongodb.repository.Query import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@ -14,4 +15,7 @@ interface FindingRepository : ReactiveMongoRepository<FindingEntity, String> {
@Query("{'data._id' :{\$in: ?0 }}") @Query("{'data._id' :{\$in: ?0 }}")
fun findFindingsByIds(id: List<String>): Flux<FindingEntity> fun findFindingsByIds(id: List<String>): Flux<FindingEntity>
@DeleteQuery("{'data._id' : ?0}")
fun deleteFindingById(id: String): Mono<Long>
} }

View File

@ -124,4 +124,32 @@ class FindingService(private val findingRepository: FindingRepository, private v
throw ex throw ex
} }
} }
fun deleteFindingByPentestAndFindingId(pentestId: String, findingId: String): Mono<Finding> {
return findingRepository.findFindingById(findingId).switchIfEmpty {
logger.info("Finding with id $findingId not found. Deletion not necessary.")
Mono.empty()
}.flatMap { findingEntity: FindingEntity ->
val finding = findingEntity.toFinding()
findingRepository.deleteFindingById(findingId).flatMap {
// After successfully deleting finding remove its id from pentest
pentestService.removeFindingFromPentest(pentestId, findingId).onErrorMap {
TransactionInterruptedException(
"Pentest could not be updated in Database.",
Errorcode.PentestInsertionFailed
)
}.map {
finding
}
}.doOnError {
throw wrappedException(
logging = { logger.warn("Finding could not be deleted from Database. Thrown exception: ", it) },
mappedException = TransactionInterruptedException(
"Finding could not be deleted.",
Errorcode.FindingDeletionFailed
)
)
}
}
}
} }

View File

@ -6,11 +6,13 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import com.securityc4po.api.ResponseBody import com.securityc4po.api.ResponseBody
import com.securityc4po.api.finding.FindingRequestBody import com.securityc4po.api.finding.FindingRequestBody
import com.securityc4po.api.finding.FindingService import com.securityc4po.api.finding.FindingService
import com.securityc4po.api.finding.toFindingDeleteResponseBody
import com.securityc4po.api.finding.toFindingResponseBody import com.securityc4po.api.finding.toFindingResponseBody
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.http.ResponseEntity.noContent import org.springframework.http.ResponseEntity.noContent
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.kotlin.core.publisher.switchIfEmpty
@RestController @RestController
@RequestMapping("/pentests") @RequestMapping("/pentests")
@ -108,4 +110,16 @@ class PentestController(private val pentestService: PentestService, private val
ResponseEntity.accepted().body(it.toFindingResponseBody()) ResponseEntity.accepted().body(it.toFindingResponseBody())
} }
} }
@DeleteMapping("/{pentestId}/finding/{findingId}")
fun deleteFinding(
@PathVariable(value = "pentestId") pentestId: String,
@PathVariable(value = "findingId") findingId: String
): Mono<ResponseEntity<ResponseBody>> {
return this.findingService.deleteFindingByPentestAndFindingId(pentestId, findingId).map {
ResponseEntity.ok().body(it.toFindingDeleteResponseBody())
}.switchIfEmpty {
Mono.just(ResponseEntity.noContent().build<ResponseBody>())
}
}
} }

View File

@ -154,6 +154,40 @@ class PentestService(private val pentestRepository: PentestRepository, private v
} }
} }
/**
* Update [Pentest] for Finding
*
* @throws [InvalidModelException] if the [Pentest] is invalid
* @throws [TransactionInterruptedException] if the [Pentest] could not be updated
* @return updated [Pentest]
*/
fun removeFindingFromPentest(pentestId: String, findingId: String): Mono<Pentest> {
return pentestRepository.findPentestById(pentestId).switchIfEmpty {
logger.warn("Pentest with id $pentestId not found. Updating not possible.")
val msg = "Pentest with id $pentestId not found."
val ex = EntityNotFoundException(msg, Errorcode.PentestNotFound)
throw ex
}.flatMap { currentPentestEntity: PentestEntity ->
if (currentPentestEntity.data.findingIds.find { pentestData -> pentestData == findingId } != null) {
val findingIds = currentPentestEntity.data.findingIds.toMutableList()
findingIds.remove(findingId)
currentPentestEntity.data.findingIds = findingIds.toList()
}
currentPentestEntity.lastModified = Instant.now()
this.pentestRepository.save(currentPentestEntity).map {
it.toPentest()
}.doOnError {
throw wrappedException(
logging = { logger.warn("Pentest could not be updated in Database. Thrown exception: ", it) },
mappedException = TransactionInterruptedException(
"Pentest could not be updated.",
Errorcode.PentestInsertionFailed
)
)
}
}
}
/** /**
* Get all [Finding]Id's by pentestId * Get all [Finding]Id's by pentestId
* *

View File

@ -14,6 +14,4 @@ interface ProjectRepository: ReactiveMongoRepository<ProjectEntity, String> {
@DeleteQuery("{'data._id' : ?0}") @DeleteQuery("{'data._id' : ?0}")
fun deleteProjectById(id: String): Mono<Long> fun deleteProjectById(id: String): Mono<Long>
} }

View File

@ -261,7 +261,7 @@ class PentestControllerDocumentationTest : BaseDocumentationIntTest() {
), ),
PayloadDocumentation.relaxedResponseFields( PayloadDocumentation.relaxedResponseFields(
PayloadDocumentation.fieldWithPath("[].id").type(JsonFieldType.STRING) PayloadDocumentation.fieldWithPath("[].id").type(JsonFieldType.STRING)
.description("The id of the requested pentest"), .description("The id of the requested findings"),
PayloadDocumentation.fieldWithPath("[].severity").type(JsonFieldType.STRING) PayloadDocumentation.fieldWithPath("[].severity").type(JsonFieldType.STRING)
.description("The severity of the finding"), .description("The severity of the finding"),
PayloadDocumentation.fieldWithPath("[].title").type(JsonFieldType.STRING) PayloadDocumentation.fieldWithPath("[].title").type(JsonFieldType.STRING)
@ -325,7 +325,7 @@ class PentestControllerDocumentationTest : BaseDocumentationIntTest() {
), ),
PayloadDocumentation.relaxedResponseFields( PayloadDocumentation.relaxedResponseFields(
PayloadDocumentation.fieldWithPath("id").type(JsonFieldType.STRING) PayloadDocumentation.fieldWithPath("id").type(JsonFieldType.STRING)
.description("The id of the requested pentest"), .description("The id of the requested finding"),
PayloadDocumentation.fieldWithPath("severity").type(JsonFieldType.STRING) PayloadDocumentation.fieldWithPath("severity").type(JsonFieldType.STRING)
.description("The severity of the finding"), .description("The severity of the finding"),
PayloadDocumentation.fieldWithPath("title").type(JsonFieldType.STRING) PayloadDocumentation.fieldWithPath("title").type(JsonFieldType.STRING)
@ -386,7 +386,7 @@ class PentestControllerDocumentationTest : BaseDocumentationIntTest() {
), ),
PayloadDocumentation.relaxedResponseFields( PayloadDocumentation.relaxedResponseFields(
PayloadDocumentation.fieldWithPath("id").type(JsonFieldType.STRING) PayloadDocumentation.fieldWithPath("id").type(JsonFieldType.STRING)
.description("The id of the requested pentest"), .description("The id of the saved finding"),
PayloadDocumentation.fieldWithPath("severity").type(JsonFieldType.STRING) PayloadDocumentation.fieldWithPath("severity").type(JsonFieldType.STRING)
.description("The severity of the finding"), .description("The severity of the finding"),
PayloadDocumentation.fieldWithPath("title").type(JsonFieldType.STRING) PayloadDocumentation.fieldWithPath("title").type(JsonFieldType.STRING)
@ -446,7 +446,7 @@ class PentestControllerDocumentationTest : BaseDocumentationIntTest() {
), ),
PayloadDocumentation.relaxedResponseFields( PayloadDocumentation.relaxedResponseFields(
PayloadDocumentation.fieldWithPath("id").type(JsonFieldType.STRING) PayloadDocumentation.fieldWithPath("id").type(JsonFieldType.STRING)
.description("The id of the requested pentest"), .description("The id of the updated finding"),
PayloadDocumentation.fieldWithPath("severity").type(JsonFieldType.STRING) PayloadDocumentation.fieldWithPath("severity").type(JsonFieldType.STRING)
.description("The severity of the finding"), .description("The severity of the finding"),
PayloadDocumentation.fieldWithPath("title").type(JsonFieldType.STRING) PayloadDocumentation.fieldWithPath("title").type(JsonFieldType.STRING)
@ -477,6 +477,43 @@ class PentestControllerDocumentationTest : BaseDocumentationIntTest() {
) )
} }
@Nested
inner class DeleteFinding {
@Test
fun deleteFindingByPentestAndFindingId() {
val pentestId = "43fbc63c-f624-11ec-b939-0242ac120002"
val findingId = "ab62d365-1b1d-4da1-89bc-5496616e220f"
webTestClient.delete()
.uri("/pentests/{pentestId}/finding/{findingId}", pentestId, findingId)
.header("Authorization", "Bearer $tokenAdmin")
.exchange()
.expectStatus().isOk
.expectHeader().doesNotExist("")
.expectBody()
.consumeWith(
WebTestClientRestDocumentation.document(
"{methodName}",
Preprocessors.preprocessRequest(
Preprocessors.prettyPrint(),
Preprocessors.modifyUris().removePort(),
Preprocessors.removeHeaders("Host", "Content-Length")
),
Preprocessors.preprocessResponse(
Preprocessors.prettyPrint()
),
RequestDocumentation.relaxedPathParameters(
RequestDocumentation.parameterWithName("pentestId").description("The id of the pentest you want to remove the finidng from"),
RequestDocumentation.parameterWithName("findingId").description("The id of the finding you want to delete")
),
PayloadDocumentation.relaxedResponseFields(
PayloadDocumentation.fieldWithPath("id").type(JsonFieldType.STRING)
.description("The id of the finding you deleted")
)
)
)
}
}
private fun persistBasicTestScenario() { private fun persistBasicTestScenario() {
// setup test data // setup test data
// Project // Project

View File

@ -281,6 +281,23 @@ class PentestControllerIntTest : BaseIntTest() {
) )
} }
@Nested
inner class DeleteFinding {
@Test
fun `delete finding successfully`() {
val pentestId = "43fbc63c-f624-11ec-b939-0242ac120002"
val findingId = "ab62d365-1b1d-4da1-89bc-5496616e220f"
webTestClient.delete()
.uri("/pentests/{pentestId}/finding/{findingId}", pentestId, findingId)
.header("Authorization", "Bearer $tokenAdmin")
.exchange()
.expectStatus().isOk
.expectHeader().valueEquals("Application-Name", "SecurityC4PO")
.expectBody()
.jsonPath("$.id").isEqualTo("ab62d365-1b1d-4da1-89bc-5496616e220f")
}
}
private fun persistBasicTestScenario() { private fun persistBasicTestScenario() {
// setup test data // setup test data
// project // project