diff --git a/calm-hub/PERMISSIONS.md b/calm-hub/PERMISSIONS.md index 1bbf4b40c..a88e9a62a 100644 --- a/calm-hub/PERMISSIONS.md +++ b/calm-hub/PERMISSIONS.md @@ -90,7 +90,12 @@ canWrite(username, namespace): | `write` | Domain `D` | Read + write content in `D` | Flat — no hierarchy | | `admin` | Namespace `N` | Read + write content in `N` and descendants; list/grant/revoke entitlements in `N` and descendants; create child namespaces of `N` | **OR any ancestor** | | `admin` | Domain `D` | Read + write content in `D`; list/grant/revoke entitlements for `D` | Flat — no hierarchy | -| `admin` | `GLOBAL` | Create/delete any namespace or domain; read + write all content; manage all entitlements (including further `GLOBAL admin` grants) | Bypasses all checks via `hasGlobalAdmin()` — only `admin` is valid; `read`/`write` grants on `GLOBAL` are rejected with 400 | +| `admin` | `GLOBAL` | Create/delete any namespace or domain; delete any architecture, pattern, flow, standard, interface, timeline, ADR, decorator, control requirement, or control configuration; read + write all content; manage all entitlements (including further `GLOBAL admin` grants) | Bypasses all checks via `hasGlobalAdmin()` — only `admin` is valid; `read`/`write` grants on `GLOBAL` are rejected with 400 | + +Content-resource deletion is deliberately `GLOBAL admin`-only — a namespace- or +domain-scoped `admin` grant does not permit it, even for content the grant +otherwise gives full read/write access to. A control requirement refuses to +delete (`409`) while it still has configurations; delete those first. --- diff --git a/calm-hub/decisions/0001-versioned-artefact-storage.md b/calm-hub/decisions/0001-versioned-artefact-storage.md index 7c30feb64..b6bfe758b 100644 --- a/calm-hub/decisions/0001-versioned-artefact-storage.md +++ b/calm-hub/decisions/0001-versioned-artefact-storage.md @@ -14,6 +14,13 @@ the old shape too. Both are excluded by revisiting with the implementation experience it was waiting for. Tracked in [#2884](https://github.com/finos/architecture-as-code/issues/2884). +The "nothing is ever deleted" premise below described the old shape and no +longer holds: every type this ADR covers, plus Control and Decorator, now +has a `GLOBAL admin`-gated `DELETE` endpoint that removes a resource and all +of its versions outright. See `PERMISSIONS.md` and +`store/util/MongoVersionDocumentStore#deleteResource` / +`NitriteVersionDocumentStore#deleteResource`. + ## Context Every Mongo store in `calm-hub` (`store/mongo/`) uses a **one document per diff --git a/calm-hub/decisions/0003-shared-version-store-helper.md b/calm-hub/decisions/0003-shared-version-store-helper.md index e2305a4c8..7d1eef220 100644 --- a/calm-hub/decisions/0003-shared-version-store-helper.md +++ b/calm-hub/decisions/0003-shared-version-store-helper.md @@ -106,8 +106,10 @@ the primitive operations every store needs against its that fails, removing the header again. The split shape makes that compensation necessary — the old shape wrote the resource and its first version in one document write, so a failure left nothing behind — and - there is no delete endpoint for any of these types, so a header stranded - with `versionCount: 0` stays visible in listings and search permanently. + without it a header stranded with `versionCount: 0` stays visible in + listings and search until an admin notices and deletes it by hand via the + `deleteResource` endpoint added later; `deleteHeader` exists so that never + has to happen in the ordinary case. That is why it belongs here rather than in each store. A per-store copy is a correctness routine duplicated once per type per backend, fourteen diff --git a/calm-hub/src/integration-test/java/integration/MongoArchitectureIntegration.java b/calm-hub/src/integration-test/java/integration/MongoArchitectureIntegration.java index f8f41d706..1f52e4820 100644 --- a/calm-hub/src/integration-test/java/integration/MongoArchitectureIntegration.java +++ b/calm-hub/src/integration-test/java/integration/MongoArchitectureIntegration.java @@ -180,4 +180,35 @@ void end_to_end_limit_slices_the_summary_list() { .statusCode(200) .body("values", hasSize(2)); } + + @Test + @Order(9) + void end_to_end_delete_an_architecture() { + given() + .when().delete("/api/calm/namespaces/finos/architectures/1") + .then() + .statusCode(204); + + // Deleting removes the whole resource, all versions included — not just the latest. + given() + .when().get("/api/calm/namespaces/finos/architectures/1/versions/1.0.0") + .then() + .statusCode(404); + + given() + .when().get("/api/calm/namespaces/finos/architectures") + .then() + .statusCode(200) + .body("values", hasSize(1)) + .body("values[0].id", equalTo(2)); + } + + @Test + @Order(10) + void end_to_end_delete_a_missing_architecture_returns_404() { + given() + .when().delete("/api/calm/namespaces/finos/architectures/999") + .then() + .statusCode(404); + } } \ No newline at end of file diff --git a/calm-hub/src/integration-test/java/integration/MongoControlIntegration.java b/calm-hub/src/integration-test/java/integration/MongoControlIntegration.java index b481464cd..d1cc95021 100644 --- a/calm-hub/src/integration-test/java/integration/MongoControlIntegration.java +++ b/calm-hub/src/integration-test/java/integration/MongoControlIntegration.java @@ -213,11 +213,15 @@ void end_to_end_get_configurations_returns_404_for_invalid_control() { @Test @Order(15) - void end_to_end_get_configuration_returns_404_for_nonexistent_config() { + void end_to_end_get_configuration_by_id_alone_returns_405() { + // There is no GET at this exact path — only .../configurations/{id}/versions[...] — + // so it always fell through to a 404 "no matching route". Since the DELETE endpoint + // now claims this exact path, JAX-RS correctly reports 405 (path matched, method + // didn't) instead of 404 (nothing matched). given() .when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/configurations/999") .then() - .statusCode(404); + .statusCode(405); } @Test @@ -552,4 +556,98 @@ void end_to_end_create_requirement_version_stores_only_inner_json_and_updates_wr .body("values.find { it.id == 1 }.name", equalTo("Final Access Control")) .body("values.find { it.id == 1 }.description", equalTo("Final")); } + + // --- Delete: requirement + configuration --- + // + // Uses a freshly created control (rather than control 1, already exercised above) so this + // scenario is self-contained and doesn't depend on the ordering or accumulated state of the + // tests above. + + @Test + @Order(50) + void end_to_end_delete_control_refuses_while_configurations_exist_then_succeeds() throws JsonProcessingException { + CreateControlRequirement requirementRequest = new CreateControlRequirement( + "Delete Test Control", "Control created to exercise delete", "{\"type\": \"requirement\"}"); + + String location = given() + .body(objectMapper.writeValueAsString(requirementRequest)) + .header("Content-Type", "application/json") + .when().post("/api/calm/domains/" + VALID_DOMAIN + "/controls") + .then() + .statusCode(201) + .extract().header("Location"); + int controlId = Integer.parseInt(location.substring(location.lastIndexOf('/') + 1)); + + CreateControlConfiguration configRequest = new CreateControlConfiguration("{\"setting\": \"enabled\"}"); + String configLocation = given() + .body(objectMapper.writeValueAsString(configRequest)) + .header("Content-Type", "application/json") + .when().post("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/configurations") + .then() + .statusCode(201) + .extract().header("Location"); + int configId = Integer.parseInt(configLocation.substring(configLocation.lastIndexOf('/') + 1)); + + // Refuses while the configuration still exists — does not cascade. + given() + .when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId) + .then() + .statusCode(409) + .body(containsString("configuration")); + + // Requirement is untouched by the refused delete. + given() + .when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/requirement/versions/1.0.0") + .then() + .statusCode(200); + + // Delete the configuration first... + given() + .when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/configurations/" + configId) + .then() + .statusCode(204); + + given() + .when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/configurations/" + configId + "/versions") + .then() + .statusCode(404); + + // ...then the requirement can be deleted. + given() + .when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId) + .then() + .statusCode(204); + + given() + .when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/requirement/versions/1.0.0") + .then() + .statusCode(404); + } + + @Test + @Order(51) + void end_to_end_delete_control_returns_404_for_missing_control() { + given() + .when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/99999") + .then() + .statusCode(404); + } + + @Test + @Order(52) + void end_to_end_delete_control_returns_404_for_invalid_domain() { + given() + .when().delete("/api/calm/domains/" + INVALID_DOMAIN + "/controls/1") + .then() + .statusCode(404); + } + + @Test + @Order(53) + void end_to_end_delete_configuration_returns_404_for_missing_configuration() { + given() + .when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/configurations/99999") + .then() + .statusCode(404); + } } diff --git a/calm-hub/src/integration-test/java/integration/NitriteArchitectureIntegration.java b/calm-hub/src/integration-test/java/integration/NitriteArchitectureIntegration.java index d426d1e8a..25d6a6c26 100644 --- a/calm-hub/src/integration-test/java/integration/NitriteArchitectureIntegration.java +++ b/calm-hub/src/integration-test/java/integration/NitriteArchitectureIntegration.java @@ -109,4 +109,34 @@ void end_to_end_reject_malformed_json_on_versioned_put() { .statusCode(400) .body(containsString("could not be parsed")); } + + @Test + @Order(7) + void end_to_end_delete_an_architecture() { + given() + .when().delete("/api/calm/namespaces/finos/architectures/1") + .then() + .statusCode(204); + + // Deleting removes the whole resource, all versions included — not just the latest. + given() + .when().get("/api/calm/namespaces/finos/architectures/1/versions/1.0.0") + .then() + .statusCode(404); + + given() + .when().get("/api/calm/namespaces/finos/architectures") + .then() + .statusCode(200) + .body("values", empty()); + } + + @Test + @Order(8) + void end_to_end_delete_a_missing_architecture_returns_404() { + given() + .when().delete("/api/calm/namespaces/finos/architectures/999") + .then() + .statusCode(404); + } } diff --git a/calm-hub/src/integration-test/java/integration/NitriteControlIntegration.java b/calm-hub/src/integration-test/java/integration/NitriteControlIntegration.java index a735501cd..f65801cb9 100644 --- a/calm-hub/src/integration-test/java/integration/NitriteControlIntegration.java +++ b/calm-hub/src/integration-test/java/integration/NitriteControlIntegration.java @@ -189,11 +189,15 @@ void end_to_end_get_configurations_returns_404_for_invalid_control() { @Test @Order(15) - void end_to_end_get_configuration_returns_404_for_nonexistent_config() { + void end_to_end_get_configuration_by_id_alone_returns_405() { + // There is no GET at this exact path — only .../configurations/{id}/versions[...] — + // so it always fell through to a 404 "no matching route". Since the DELETE endpoint + // now claims this exact path, JAX-RS correctly reports 405 (path matched, method + // didn't) instead of 404 (nothing matched). given() .when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/configurations/999") .then() - .statusCode(404); + .statusCode(405); } @Test @@ -514,4 +518,98 @@ void end_to_end_create_requirement_version_stores_only_inner_json_and_updates_wr .body("values.find { it.id == 1 }.name", equalTo("Final Access Control")) .body("values.find { it.id == 1 }.description", equalTo("Final")); } + + // --- Delete: requirement + configuration --- + // + // Uses a freshly created control (rather than control 1, already exercised above) so this + // scenario is self-contained and doesn't depend on the ordering or accumulated state of the + // tests above. + + @Test + @Order(50) + void end_to_end_delete_control_refuses_while_configurations_exist_then_succeeds() throws JsonProcessingException { + CreateControlRequirement requirementRequest = new CreateControlRequirement( + "Delete Test Control", "Control created to exercise delete", "{\"type\": \"requirement\"}"); + + String location = given() + .body(objectMapper.writeValueAsString(requirementRequest)) + .header("Content-Type", "application/json") + .when().post("/api/calm/domains/" + VALID_DOMAIN + "/controls") + .then() + .statusCode(201) + .extract().header("Location"); + int controlId = Integer.parseInt(location.substring(location.lastIndexOf('/') + 1)); + + CreateControlConfiguration configRequest = new CreateControlConfiguration("{\"setting\": \"enabled\"}"); + String configLocation = given() + .body(objectMapper.writeValueAsString(configRequest)) + .header("Content-Type", "application/json") + .when().post("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/configurations") + .then() + .statusCode(201) + .extract().header("Location"); + int configId = Integer.parseInt(configLocation.substring(configLocation.lastIndexOf('/') + 1)); + + // Refuses while the configuration still exists — does not cascade. + given() + .when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId) + .then() + .statusCode(409) + .body(containsString("configuration")); + + // Requirement is untouched by the refused delete. + given() + .when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/requirement/versions/1.0.0") + .then() + .statusCode(200); + + // Delete the configuration first... + given() + .when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/configurations/" + configId) + .then() + .statusCode(204); + + given() + .when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/configurations/" + configId + "/versions") + .then() + .statusCode(404); + + // ...then the requirement can be deleted. + given() + .when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId) + .then() + .statusCode(204); + + given() + .when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/requirement/versions/1.0.0") + .then() + .statusCode(404); + } + + @Test + @Order(51) + void end_to_end_delete_control_returns_404_for_missing_control() { + given() + .when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/99999") + .then() + .statusCode(404); + } + + @Test + @Order(52) + void end_to_end_delete_control_returns_404_for_invalid_domain() { + given() + .when().delete("/api/calm/domains/" + INVALID_DOMAIN + "/controls/1") + .then() + .statusCode(404); + } + + @Test + @Order(53) + void end_to_end_delete_configuration_returns_404_for_missing_configuration() { + given() + .when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/configurations/99999") + .then() + .statusCode(404); + } } diff --git a/calm-hub/src/main/java/org/finos/calm/domain/exception/ControlHasConfigurationsException.java b/calm-hub/src/main/java/org/finos/calm/domain/exception/ControlHasConfigurationsException.java new file mode 100644 index 000000000..652a1e58c --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/domain/exception/ControlHasConfigurationsException.java @@ -0,0 +1,31 @@ +package org.finos.calm.domain.exception; + +/** + * Thrown when a control requirement cannot be deleted because it still has + * configurations associated with it. Carries the raw control id and configuration + * count as fields rather than a formatted message — the resource layer (see + * {@code ControlResource#controlHasConfigurationsResponse}) is solely responsible + * for composing the user-facing message, so it isn't duplicated here. + */ +public class ControlHasConfigurationsException extends Exception { + private final int controlId; + private final int configurationCount; + + /** + * @param controlId the control that could not be deleted because it still has configurations + * @param configurationCount how many configurations exist under the control + */ + public ControlHasConfigurationsException(int controlId, int configurationCount) { + super("Control not empty: " + controlId); + this.controlId = controlId; + this.configurationCount = configurationCount; + } + + public int getControlId() { + return controlId; + } + + public int getConfigurationCount() { + return configurationCount; + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/resources/AdrResource.java b/calm-hub/src/main/java/org/finos/calm/resources/AdrResource.java index 6336a2c8a..0a3fd7cb5 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/AdrResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/AdrResource.java @@ -293,6 +293,33 @@ public Response updateAdrStatusForNamespace( } } + /** + * Delete an ADR and all of its revisions + * + * @param namespace the namespace the ADR is in + * @param adrId the ID of the ADR + * @return no content on success + */ + @DELETE + @Path("{namespace}/adrs/{adrId}") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Delete an ADR", + description = "Deletes an ADR and all of its revisions from the given namespace. Requires global admin privilege." + ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + public Response deleteAdrForNamespace( + @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, + @PathParam("adrId") int adrId + ) { + try { + store.deleteAdr(namespace, adrId); + } catch (Exception e) { + return handleException(e, namespace, adrId); + } + return Response.noContent().build(); + } + private Response adrWithLocationResponse(AdrMeta adrMeta) throws URISyntaxException { return Response.created(new URI("/api/calm/namespaces/" + adrMeta.getNamespace() + "/adrs/" + adrMeta.getId() + "/revisions/" + adrMeta.getRevision())).build(); } diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java b/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java index 9652a54c1..2a9a41855 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.Pattern; import jakarta.ws.rs.BeanParam; import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; @@ -32,6 +33,7 @@ import org.finos.calm.services.ArchitectureTimelineService; import org.finos.calm.services.CustomIdEnrichmentService; import org.finos.calm.store.ArchitectureStore; +import org.finos.calm.store.ResourceMappingStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -56,6 +58,7 @@ public class ArchitectureResource { private final ArchitectureStore store; private final ArchitectureTimelineService timelineService; private final CustomIdEnrichmentService customIds; + private final ResourceMappingStore mappingStore; private final Logger logger = LoggerFactory.getLogger(ArchitectureResource.class); @@ -64,10 +67,11 @@ public class ArchitectureResource { @Inject public ArchitectureResource(ArchitectureStore store, ArchitectureTimelineService timelineService, - CustomIdEnrichmentService customIds) { + CustomIdEnrichmentService customIds, ResourceMappingStore mappingStore) { this.store = store; this.timelineService = timelineService; this.customIds = customIds; + this.mappingStore = mappingStore; } /** @@ -301,6 +305,31 @@ public Response getArchitectureTimeline( } } + @DELETE + @Path("{namespace}/architectures/{architectureId}") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Delete an architecture", + description = "Deletes an architecture and all of its versions from the given namespace. Requires global admin privilege." + ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + public Response deleteArchitecture( + @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, + @PathParam("architectureId") int architectureId + ) { + try { + store.deleteArchitecture(namespace, architectureId); + } catch (NamespaceNotFoundException e) { + logger.error("Invalid namespace [{}] when deleting architecture", namespace, e); + return CalmResourceErrorResponses.invalidNamespaceResponse(namespace); + } catch (ArchitectureNotFoundException e) { + logger.error("Invalid architecture [{}] when deleting architecture", architectureId, e); + return invalidArchitectureResponse(architectureId); + } + MappingCleanup.deleteMapping(mappingStore, logger, namespace, ResourceType.ARCHITECTURE, architectureId); + return Response.noContent().build(); + } + private Response architectureWithLocationResponse(Architecture architecture) throws URISyntaxException { return Response.created(new URI("/api/calm/namespaces/" + architecture.getNamespace() + "/architectures/" + architecture.getId() + "/versions/" + architecture.getDotVersion())).build(); } diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ControlResource.java b/calm-hub/src/main/java/org/finos/calm/resources/ControlResource.java index 3a4a3f38c..60f58304c 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/ControlResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/ControlResource.java @@ -327,6 +327,63 @@ public Response createConfigurationForVersion( } } + @DELETE + @Produces(MediaType.APPLICATION_JSON) + @Path("{domain}/controls/{controlId}") + @Operation( + summary = "Delete a control requirement", + description = "Deletes a control requirement and all of its versions. Refuses if the control still has configurations. Requires global admin privilege." + ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + public Response deleteControlRequirement( + @PathParam("domain") + @Pattern(regexp = DOMAIN_REGEX, message = DOMAIN_MESSAGE) + String domain, + @PathParam("controlId") int controlId) { + try { + store.deleteControlRequirement(domain, controlId); + } catch (DomainNotFoundException e) { + logger.error("Invalid domain [{}] when deleting control", domain, e); + return invalidDomainResponse(domain); + } catch (ControlNotFoundException e) { + logger.error("Control [{}] not found in domain [{}]", controlId, domain, e); + return invalidControlResponse(controlId); + } catch (ControlHasConfigurationsException e) { + logger.error("Control [{}] has {} configuration(s) and cannot be deleted", controlId, e.getConfigurationCount(), e); + return controlHasConfigurationsResponse(e.getControlId(), e.getConfigurationCount()); + } + return Response.noContent().build(); + } + + @DELETE + @Produces(MediaType.APPLICATION_JSON) + @Path("{domain}/controls/{controlId}/configurations/{configId}") + @Operation( + summary = "Delete a control configuration", + description = "Deletes a control configuration and all of its versions. Requires global admin privilege." + ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + public Response deleteControlConfiguration( + @PathParam("domain") + @Pattern(regexp = DOMAIN_REGEX, message = DOMAIN_MESSAGE) + String domain, + @PathParam("controlId") int controlId, + @PathParam("configId") int configId) { + try { + store.deleteControlConfiguration(domain, controlId, configId); + } catch (DomainNotFoundException e) { + logger.error("Invalid domain [{}] when deleting configuration", domain, e); + return invalidDomainResponse(domain); + } catch (ControlNotFoundException e) { + logger.error("Control [{}] not found in domain [{}]", controlId, domain, e); + return invalidControlResponse(controlId); + } catch (ControlConfigurationNotFoundException e) { + logger.error("Configuration [{}] not found for control [{}]", configId, controlId, e); + return invalidConfigurationResponse(configId); + } + return Response.noContent().build(); + } + private Response invalidDomainResponse(String domain) { return Response.status(Response.Status.NOT_FOUND) .entity("Invalid domain provided: " + STRICT_SANITIZATION_POLICY.sanitize(domain)) @@ -345,6 +402,12 @@ private Response invalidConfigurationResponse(int configId) { .build(); } + private Response controlHasConfigurationsResponse(int controlId, int configurationCount) { + return Response.status(Response.Status.CONFLICT) + .entity("Control " + controlId + " has " + configurationCount + " configuration(s) and cannot be deleted") + .build(); + } + private Response invalidVersionResponse(String version) { return Response.status(Response.Status.NOT_FOUND) .entity("Version not found: " + STRICT_SANITIZATION_POLICY.sanitize(version)) diff --git a/calm-hub/src/main/java/org/finos/calm/resources/DecoratorResource.java b/calm-hub/src/main/java/org/finos/calm/resources/DecoratorResource.java index 0fcc7ec1b..3bebbd7be 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/DecoratorResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/DecoratorResource.java @@ -196,4 +196,35 @@ public Response updateDecoratorForNamespace( return CalmResourceErrorResponses.invalidNamespaceResponse(namespace); } } + + /** + * Delete an existing decorator by ID in a given namespace. + * + * @param namespace the namespace containing the decorator + * @param id the id of the decorator to delete + * @return 204 No Content, or an appropriate error response + */ + @DELETE + @Path("{namespace}/decorators/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Delete a decorator by ID in a given namespace", + description = "Deletes a decorator from the given namespace. Requires global admin privilege." + ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + public Response deleteDecoratorForNamespace( + @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, + @PathParam("id") @Min(value = 1, message = "ID must be a positive integer") int id + ) { + try { + decoratorStore.deleteDecorator(namespace, id); + } catch (DecoratorNotFoundException e) { + logger.error("Decorator [{}] not found in namespace [{}] when deleting", id, namespace, e); + return CalmResourceErrorResponses.decoratorNotFoundResponse(namespace, id); + } catch (NamespaceNotFoundException e) { + logger.error("Invalid namespace [{}] when deleting decorator [{}]", namespace, id, e); + return CalmResourceErrorResponses.invalidNamespaceResponse(namespace); + } + return Response.noContent().build(); + } } diff --git a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java index 924d59e3b..0db651ebb 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java @@ -23,6 +23,7 @@ import org.finos.calm.security.CalmHubScopes; import org.finos.calm.services.CustomIdEnrichmentService; import org.finos.calm.store.FlowStore; +import org.finos.calm.store.ResourceMappingStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -42,6 +43,7 @@ public class FlowResource { private final FlowStore store; private final CustomIdEnrichmentService customIds; + private final ResourceMappingStore mappingStore; private final Logger logger = LoggerFactory.getLogger(FlowResource.class); @@ -49,9 +51,10 @@ public class FlowResource { Boolean allowPutOperations; @Inject - public FlowResource(FlowStore store, CustomIdEnrichmentService customIds) { + public FlowResource(FlowStore store, CustomIdEnrichmentService customIds, ResourceMappingStore mappingStore) { this.store = store; this.customIds = customIds; + this.mappingStore = mappingStore; } @GET @@ -274,6 +277,31 @@ public Response updateVersionedFlow( } } + @DELETE + @Path("{namespace}/flows/{flowId}") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Delete a flow", + description = "Deletes a flow and all of its versions from the given namespace. Requires global admin privilege." + ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + public Response deleteFlow( + @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, + @PathParam("flowId") int flowId + ) { + try { + store.deleteFlow(namespace, flowId); + } catch (NamespaceNotFoundException e) { + logger.error("Invalid namespace [{}] when deleting flow", namespace, e); + return CalmResourceErrorResponses.invalidNamespaceResponse(namespace); + } catch (FlowNotFoundException e) { + logger.error("Invalid flow [{}] when deleting flow", flowId, e); + return invalidFlowResponse(flowId); + } + MappingCleanup.deleteMapping(mappingStore, logger, namespace, ResourceType.FLOW, flowId); + return Response.noContent().build(); + } + private Response flowWithLocationResponse(Flow flow) throws URISyntaxException { return Response.created(new URI("/api/calm/namespaces/" + flow.getNamespace() + "/flows/" + flow.getId() + "/versions/" + flow.getDotVersion())).build(); } diff --git a/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java b/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java index 3059fd74b..b4871d117 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java @@ -21,6 +21,7 @@ import org.finos.calm.security.CalmHubScopes; import org.finos.calm.services.CustomIdEnrichmentService; import org.finos.calm.store.InterfaceStore; +import org.finos.calm.store.ResourceMappingStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,13 +36,15 @@ public class InterfaceResource { private final InterfaceStore interfaceStore; private final CustomIdEnrichmentService customIds; + private final ResourceMappingStore mappingStore; private final Logger logger = LoggerFactory.getLogger(InterfaceResource.class); @Inject - public InterfaceResource(InterfaceStore interfaceStore, CustomIdEnrichmentService customIds) { + public InterfaceResource(InterfaceStore interfaceStore, CustomIdEnrichmentService customIds, ResourceMappingStore mappingStore) { this.interfaceStore = interfaceStore; this.customIds = customIds; + this.mappingStore = mappingStore; } @GET @@ -152,6 +155,27 @@ public Response createInterfaceForVersion( } } + @DELETE + @Path("{namespace}/interfaces/{interfaceId}") + @Produces(MediaType.APPLICATION_JSON) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + public Response deleteInterface( + @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, + @PathParam("interfaceId") Integer interfaceId + ) { + try { + interfaceStore.deleteInterface(namespace, interfaceId); + } catch (NamespaceNotFoundException e) { + logger.error("Invalid namespace [{}] when deleting interface", namespace, e); + return CalmResourceErrorResponses.invalidNamespaceResponse(namespace); + } catch (InterfaceNotFoundException e) { + logger.error("Invalid interface [{}] when deleting interface", interfaceId, e); + return invalidInterfaceResponse(interfaceId); + } + MappingCleanup.deleteMapping(mappingStore, logger, namespace, ResourceType.INTERFACE, interfaceId); + return Response.noContent().build(); + } + private Response invalidInterfaceResponse(int interfaceId) { return Response.status(Response.Status.NOT_FOUND).entity("Invalid interface provided: " + interfaceId).build(); } diff --git a/calm-hub/src/main/java/org/finos/calm/resources/MappingCleanup.java b/calm-hub/src/main/java/org/finos/calm/resources/MappingCleanup.java new file mode 100644 index 000000000..530d30d1d --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/resources/MappingCleanup.java @@ -0,0 +1,43 @@ +package org.finos.calm.resources; + +import org.finos.calm.domain.ResourceType; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.store.ResourceMappingStore; +import org.slf4j.Logger; + +/** + * Best-effort cleanup of a resource's custom-id mapping after its underlying resource has + * already been deleted, shared by the five resource types the name-based ({@code /calm}) API + * can map: Architecture, Flow, Interface, Pattern, Standard. + * + *

Without this, deleting a resource that was created (or is also reachable) through the + * name-based API leaves its {@code resource_mappings} entry behind: recreating it under the + * same custom ID then fails with {@code DuplicateMappingException}, and the custom-id route + * keeps resolving to a numeric ID that no longer exists.

+ * + *

Most resources have no mapping at all — a custom ID only exists for resources written + * through the name-based API — so {@link ResourceMappingStore#deleteMappingByNumericId} is a + * no-op in the common case. The underlying resource is already gone by the time this runs, so + * a failure here is logged and swallowed rather than failing the delete response over cleanup + * that can't be rolled back into an undelete anyway.

+ */ +final class MappingCleanup { + + private MappingCleanup() { + } + + static void deleteMapping(ResourceMappingStore mappingStore, Logger logger, + String namespace, ResourceType type, int numericId) { + try { + mappingStore.deleteMappingByNumericId(namespace, type, numericId); + } catch (NamespaceNotFoundException e) { + logger.warn("Could not clean up the mapping for {} [{}] in namespace [{}] after delete " + + "— the namespace no longer exists", type, numericId, namespace, e); + } catch (RuntimeException e) { + // A driver/DB failure (MongoException, a Nitrite lock/store error, ...) must not + // surface as an unhandled 500 for a delete that has already succeeded. + logger.warn("Could not clean up the mapping for {} [{}] in namespace [{}] after delete", + type, numericId, namespace, e); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java index df6bf153b..deafc0903 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java @@ -22,6 +22,7 @@ import org.finos.calm.security.CalmHubScopes; import org.finos.calm.services.CustomIdEnrichmentService; import org.finos.calm.store.PatternStore; +import org.finos.calm.store.ResourceMappingStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,6 +41,7 @@ public class PatternResource { private final PatternStore store; private final CustomIdEnrichmentService customIds; + private final ResourceMappingStore mappingStore; private final Logger logger = LoggerFactory.getLogger(PatternResource.class); @@ -47,9 +49,10 @@ public class PatternResource { Boolean allowPutOperations; @Inject - public PatternResource(PatternStore store, CustomIdEnrichmentService customIds) { + public PatternResource(PatternStore store, CustomIdEnrichmentService customIds, ResourceMappingStore mappingStore) { this.store = store; this.customIds = customIds; + this.mappingStore = mappingStore; } @GET @@ -248,6 +251,31 @@ public Response updateVersionedPattern( } + @DELETE + @Path("{namespace}/patterns/{patternId}") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Delete a pattern", + description = "Deletes a pattern and all of its versions from the given namespace. Requires global admin privilege." + ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + public Response deletePattern( + @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, + @PathParam("patternId") int patternId + ) { + try { + store.deletePattern(namespace, patternId); + } catch (NamespaceNotFoundException e) { + logger.error("Invalid namespace [{}] when deleting pattern", namespace, e); + return CalmResourceErrorResponses.invalidNamespaceResponse(namespace); + } catch (PatternNotFoundException e) { + logger.error("Invalid pattern [{}] when deleting pattern", patternId, e); + return invalidPatternResponse(patternId); + } + MappingCleanup.deleteMapping(mappingStore, logger, namespace, ResourceType.PATTERN, patternId); + return Response.noContent().build(); + } + private Response patternWithLocationResponse(Pattern pattern) throws URISyntaxException { return Response.created(new URI("/api/calm/namespaces/" + pattern.getNamespace() + "/patterns/" + pattern.getId() + "/versions/" + pattern.getDotVersion())).build(); } diff --git a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java index 07ede7013..5f79eb610 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java @@ -16,6 +16,7 @@ import org.finos.calm.domain.standards.CreateStandardRequest; import org.finos.calm.security.CalmHubScopes; import org.finos.calm.services.CustomIdEnrichmentService; +import org.finos.calm.store.ResourceMappingStore; import org.finos.calm.store.StandardStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,12 +32,14 @@ public class StandardResource { private final StandardStore standardStore; private final CustomIdEnrichmentService customIds; + private final ResourceMappingStore mappingStore; private final Logger logger = LoggerFactory.getLogger(StandardResource.class); - public StandardResource(StandardStore standardStore, CustomIdEnrichmentService customIds) { + public StandardResource(StandardStore standardStore, CustomIdEnrichmentService customIds, ResourceMappingStore mappingStore) { this.standardStore = standardStore; this.customIds = customIds; + this.mappingStore = mappingStore; } @GET @@ -142,6 +145,27 @@ public Response createStandardForVersion( } } + @DELETE + @Path("{namespace}/standards/{standardId}") + @Produces(MediaType.APPLICATION_JSON) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + public Response deleteStandard( + @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, + @PathParam("standardId") Integer standardId + ) { + try { + standardStore.deleteStandard(namespace, standardId); + } catch (NamespaceNotFoundException e) { + logger.error("Invalid namespace [{}] when deleting standard", namespace, e); + return CalmResourceErrorResponses.invalidNamespaceResponse(namespace); + } catch (StandardNotFoundException e) { + logger.error("Invalid standard [{}] when deleting standard", standardId, e); + return invalidStandardResponse(standardId); + } + MappingCleanup.deleteMapping(mappingStore, logger, namespace, ResourceType.STANDARD, standardId); + return Response.noContent().build(); + } + private Response invalidStandardResponse(int standardId) { return Response.status(Response.Status.NOT_FOUND).entity("Invalid standard provided: " + standardId).build(); } diff --git a/calm-hub/src/main/java/org/finos/calm/resources/TimelineResource.java b/calm-hub/src/main/java/org/finos/calm/resources/TimelineResource.java index a8b64f1ca..e79faf25f 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/TimelineResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/TimelineResource.java @@ -238,6 +238,30 @@ public Response updateVersionedTimeline( } } + @DELETE + @Path("{namespace}/timelines/{timelineId}") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Delete a timeline", + description = "Deletes a timeline and all of its versions from the given namespace. Requires global admin privilege." + ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + public Response deleteTimeline( + @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, + @PathParam("timelineId") int timelineId + ) { + try { + store.deleteTimeline(namespace, timelineId); + } catch (NamespaceNotFoundException e) { + logger.error("Invalid namespace [{}] when deleting timeline", namespace, e); + return CalmResourceErrorResponses.invalidNamespaceResponse(namespace); + } catch (TimelineNotFoundException e) { + logger.error("Invalid timeline [{}] when deleting timeline", timelineId, e); + return invalidTimelineResponse(timelineId); + } + return Response.noContent().build(); + } + private Response timelineWithLocationResponse(Timeline timeline) throws URISyntaxException { return Response.created(new URI("/api/calm/namespaces/" + timeline.getNamespace() + "/timelines/" + timeline.getId() + "/versions/" + timeline.getDotVersion())).build(); } diff --git a/calm-hub/src/main/java/org/finos/calm/security/AuditRequestFilter.java b/calm-hub/src/main/java/org/finos/calm/security/AuditRequestFilter.java index d29093037..549ede538 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/AuditRequestFilter.java +++ b/calm-hub/src/main/java/org/finos/calm/security/AuditRequestFilter.java @@ -353,6 +353,10 @@ private AuditContext resolveControlResource(String methodName, UriInfo uriInfo, AuditEntityType.CONTROL_CONFIGURATION, domain, outcome, responseContext); case "createConfigurationForVersion" -> new AuditContext(AuditEntityType.CONTROL_CONFIGURATION, AuditAction.UPDATE, null, domain, uriInfo.getPathParameters().getFirst("configId"), version); + case "deleteControlRequirement" -> new AuditContext(AuditEntityType.CONTROL_REQUIREMENT, + AuditAction.DELETE, null, domain, uriInfo.getPathParameters().getFirst("controlId"), null); + case "deleteControlConfiguration" -> new AuditContext(AuditEntityType.CONTROL_CONFIGURATION, + AuditAction.DELETE, null, domain, uriInfo.getPathParameters().getFirst("configId"), null); default -> null; }; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/AdrStore.java b/calm-hub/src/main/java/org/finos/calm/store/AdrStore.java index 3c6d6f619..7d583093b 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/AdrStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/AdrStore.java @@ -32,4 +32,9 @@ public interface AdrStore { AdrMeta getAdrRevision(AdrMeta adrMeta) throws NamespaceNotFoundException, AdrNotFoundException, AdrRevisionNotFoundException, AdrParseException; AdrMeta updateAdrForNamespace(AdrMeta adrMeta) throws NamespaceNotFoundException, AdrNotFoundException, AdrRevisionNotFoundException, AdrPersistenceException, AdrParseException, AdrRevisionExistsException; AdrMeta updateAdrStatus(AdrMeta adrMeta, Status status) throws AdrNotFoundException, NamespaceNotFoundException, AdrRevisionNotFoundException, AdrPersistenceException, AdrParseException, AdrRevisionExistsException; + + /** + * Deletes an ADR and all of its revisions. + */ + void deleteAdr(String namespace, int adrId) throws NamespaceNotFoundException, AdrNotFoundException; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/ArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/ArchitectureStore.java index c0d475fdd..9279a03c0 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/ArchitectureStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/ArchitectureStore.java @@ -35,4 +35,9 @@ default List getArchitecturesForNamespace(String names String getArchitectureForVersion(Architecture architecture) throws NamespaceNotFoundException, ArchitectureNotFoundException, ArchitectureVersionNotFoundException; Architecture createArchitectureForVersion(Architecture architecture) throws NamespaceNotFoundException, ArchitectureNotFoundException, ArchitectureVersionExistsException; Architecture updateArchitectureForVersion(Architecture architecture) throws NamespaceNotFoundException, ArchitectureNotFoundException; + + /** + * Deletes an architecture and all of its versions. + */ + void deleteArchitecture(String namespace, int architectureId) throws NamespaceNotFoundException, ArchitectureNotFoundException; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/ControlStore.java b/calm-hub/src/main/java/org/finos/calm/store/ControlStore.java index a57619b8e..848c1edc3 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/ControlStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/ControlStore.java @@ -7,6 +7,7 @@ import org.finos.calm.domain.exception.ControlConfigurationNotFoundException; import org.finos.calm.domain.exception.ControlConfigurationVersionNotFoundException; import org.finos.calm.domain.exception.ControlConfigurationVersionExistsException; +import org.finos.calm.domain.exception.ControlHasConfigurationsException; import org.finos.calm.domain.exception.ControlNotFoundException; import org.finos.calm.domain.exception.ControlRequirementVersionExistsException; import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; @@ -29,4 +30,15 @@ public interface ControlStore { List getConfigurationVersions(String domain, int controlId, int configurationId) throws DomainNotFoundException, ControlNotFoundException, ControlConfigurationNotFoundException; String getConfigurationForVersion(String domain, int controlId, int configurationId, String version) throws DomainNotFoundException, ControlNotFoundException, ControlConfigurationNotFoundException, ControlConfigurationVersionNotFoundException; void createConfigurationForVersion(String domain, int controlId, int configurationId, String version, CreateControlConfiguration request) throws DomainNotFoundException, ControlNotFoundException, ControlConfigurationNotFoundException, ControlConfigurationVersionExistsException; + + /** + * Deletes a control requirement and all of its versions. Refuses if the control still + * has configurations, rather than cascading — see {@link ControlHasConfigurationsException}. + */ + void deleteControlRequirement(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException, ControlHasConfigurationsException; + + /** + * Deletes a control configuration and all of its versions. + */ + void deleteControlConfiguration(String domain, int controlId, int configurationId) throws DomainNotFoundException, ControlNotFoundException, ControlConfigurationNotFoundException; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/DecoratorStore.java b/calm-hub/src/main/java/org/finos/calm/store/DecoratorStore.java index 3c74750d6..971f22225 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/DecoratorStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/DecoratorStore.java @@ -64,4 +64,14 @@ public interface DecoratorStore { * @throws DecoratorNotFoundException if no decorator with the given ID exists */ void updateDecorator(String namespace, int id, String decoratorJson) throws NamespaceNotFoundException, DecoratorNotFoundException; + + /** + * Delete an existing decorator in the given namespace. + * + * @param namespace the namespace containing the decorator + * @param id the ID of the decorator to delete + * @throws NamespaceNotFoundException if the namespace does not exist + * @throws DecoratorNotFoundException if no decorator with the given ID exists + */ + void deleteDecorator(String namespace, int id) throws NamespaceNotFoundException, DecoratorNotFoundException; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/FlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/FlowStore.java index 77d7c9be0..43211b3c3 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/FlowStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/FlowStore.java @@ -17,4 +17,9 @@ public interface FlowStore { String getFlowForVersion(Flow flow) throws NamespaceNotFoundException, FlowNotFoundException, FlowVersionNotFoundException; Flow createFlowForVersion(Flow flow) throws NamespaceNotFoundException, FlowNotFoundException, FlowVersionExistsException; Flow updateFlowForVersion(Flow flow) throws NamespaceNotFoundException, FlowNotFoundException; + + /** + * Deletes a flow and all of its versions. + */ + void deleteFlow(String namespace, int flowId) throws NamespaceNotFoundException, FlowNotFoundException; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java index 1eede59dd..69ef23aa0 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java @@ -16,4 +16,9 @@ public interface InterfaceStore { List getInterfaceVersions(String namespace, Integer interfaceId) throws NamespaceNotFoundException, InterfaceNotFoundException; String getInterfaceForVersion(String namespace, Integer interfaceId, String version) throws NamespaceNotFoundException, InterfaceNotFoundException, InterfaceVersionNotFoundException; CalmInterface createInterfaceForVersion(CreateInterfaceRequest interfaceRequest, String namespace, Integer interfaceId, String version) throws NamespaceNotFoundException, InterfaceNotFoundException, InterfaceVersionExistsException; + + /** + * Deletes an interface and all of its versions. + */ + void deleteInterface(String namespace, Integer interfaceId) throws NamespaceNotFoundException, InterfaceNotFoundException; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/PatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/PatternStore.java index 4a12bbf62..de5bf622b 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/PatternStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/PatternStore.java @@ -37,4 +37,9 @@ default List getPatternsForNamespace(String namespace) String getPatternForVersion(Pattern pattern) throws NamespaceNotFoundException, PatternNotFoundException, PatternVersionNotFoundException; Pattern createPatternForVersion(Pattern pattern) throws NamespaceNotFoundException, PatternNotFoundException, PatternVersionExistsException; Pattern updatePatternForVersion(Pattern pattern) throws NamespaceNotFoundException, PatternNotFoundException; + + /** + * Deletes a pattern and all of its versions. + */ + void deletePattern(String namespace, int patternId) throws NamespaceNotFoundException, PatternNotFoundException; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/ResourceMappingStore.java b/calm-hub/src/main/java/org/finos/calm/store/ResourceMappingStore.java index 837a7ad7a..cdc194b75 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/ResourceMappingStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/ResourceMappingStore.java @@ -16,4 +16,14 @@ public interface ResourceMappingStore { List listMappingsByNumericIds(String namespace, ResourceType type, List ids) throws NamespaceNotFoundException; void updateMappingNumericId(String namespace, ResourceType type, String customId, int numericId) throws MappingNotFoundException, NamespaceNotFoundException; void deleteMapping(String namespace, ResourceType type, String customId) throws MappingNotFoundException, NamespaceNotFoundException; + + /** + * Deletes the mapping for a resource by its numeric ID, if one exists. + * + *

A custom ID exists only for resources written through the name-based ({@code /calm}) + * API, so most numeric-ID resources have no mapping to clean up at all. This is a no-op + * rather than throwing when nothing matches, so a resource delete can call it + * unconditionally instead of first checking whether a mapping exists.

+ */ + void deleteMappingByNumericId(String namespace, ResourceType type, int numericId) throws NamespaceNotFoundException; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java index f79420edb..6f54f2940 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java @@ -16,4 +16,9 @@ public interface StandardStore { List getStandardVersions(String namespace, Integer standardId) throws NamespaceNotFoundException, StandardNotFoundException; String getStandardForVersion(String namespace, Integer standardId, String version) throws NamespaceNotFoundException, StandardNotFoundException, StandardVersionNotFoundException; Standard createStandardForVersion(CreateStandardRequest standardRequest, String namespace, Integer standardId, String version) throws NamespaceNotFoundException, StandardNotFoundException, StandardVersionExistsException; + + /** + * Deletes a standard and all of its versions. + */ + void deleteStandard(String namespace, Integer standardId) throws NamespaceNotFoundException, StandardNotFoundException; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/TimelineStore.java b/calm-hub/src/main/java/org/finos/calm/store/TimelineStore.java index 3d47fe23e..9798bfc50 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/TimelineStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/TimelineStore.java @@ -17,4 +17,9 @@ public interface TimelineStore { String getTimelineForVersion(Timeline timeline) throws NamespaceNotFoundException, TimelineNotFoundException, TimelineVersionNotFoundException; Timeline createTimelineForVersion(Timeline timeline) throws NamespaceNotFoundException, TimelineNotFoundException, TimelineVersionExistsException; Timeline updateTimelineForVersion(Timeline timeline) throws NamespaceNotFoundException, TimelineNotFoundException; + + /** + * Deletes a timeline and all of its versions. + */ + void deleteTimeline(String namespace, int timelineId) throws NamespaceNotFoundException, TimelineNotFoundException; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoAdrStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoAdrStore.java index 79a322a71..a5ea4cdb9 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoAdrStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoAdrStore.java @@ -301,4 +301,12 @@ private void requireAdr(AdrMeta adrMeta) throws NamespaceNotFoundException, AdrN throw new AdrNotFoundException(); } } + + @Override + public void deleteAdr(String namespace, int adrId) throws NamespaceNotFoundException, AdrNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, adrId)) { + throw new AdrNotFoundException(); + } + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoArchitectureStore.java index e33c16a89..fc3815d46 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoArchitectureStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoArchitectureStore.java @@ -156,4 +156,12 @@ private void requireArchitecture(Architecture architecture) throws NamespaceNotF throw new ArchitectureNotFoundException(); } } + + @Override + public void deleteArchitecture(String namespace, int architectureId) throws NamespaceNotFoundException, ArchitectureNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, architectureId)) { + throw new ArchitectureNotFoundException(); + } + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoControlStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoControlStore.java index 2b145732d..2b309fe98 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoControlStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoControlStore.java @@ -12,6 +12,7 @@ import org.finos.calm.domain.exception.ControlConfigurationNotFoundException; import org.finos.calm.domain.exception.ControlConfigurationVersionExistsException; import org.finos.calm.domain.exception.ControlConfigurationVersionNotFoundException; +import org.finos.calm.domain.exception.ControlHasConfigurationsException; import org.finos.calm.domain.exception.ControlNotFoundException; import org.finos.calm.domain.exception.ControlRequirementVersionExistsException; import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; @@ -194,6 +195,42 @@ public void createConfigurationForVersion(String domain, int controlId, int conf } } + /** + * Refuses to delete a control requirement that still has configurations, rather than + * cascading — see {@link ControlHasConfigurationsException}. + * + *

Known, accepted race: the configuration count and the delete are two separate + * calls, not one atomic operation, so a configuration created via + * {@link #createConfigurationForVersion} in the gap between them survives under a + * requirement that has just been deleted. Not fixed here, consistent with the rest of this + * store layer using no transactions or locks anywhere else: a Nitrite-only lock would not + * extend the guarantee to Mongo (this backend), and the failure mode — one configuration + * document outliving the requirement it belonged to — is a data-hygiene issue, discoverable + * and cleanable, not a correctness or security one.

+ */ + @Override + public void deleteControlRequirement(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException, ControlHasConfigurationsException { + requireControl(domain, controlId); + + int configurationCount = configurationStore.countHeaders(configurationNamespace(domain, controlId)); + if (configurationCount > 0) { + throw new ControlHasConfigurationsException(controlId, configurationCount); + } + + if (!requirementStore.deleteResource(domain, controlId)) { + throw new ControlNotFoundException(); + } + } + + @Override + public void deleteControlConfiguration(String domain, int controlId, int configurationId) throws DomainNotFoundException, ControlNotFoundException, ControlConfigurationNotFoundException { + requireConfiguration(domain, controlId, configurationId); + + if (!configurationStore.deleteResource(configurationNamespace(domain, controlId), configurationId)) { + throw new ControlConfigurationNotFoundException(); + } + } + private void requireControl(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException { validateDomain(domain); if (!requirementStore.headerExists(domain, controlId)) { diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoDecoratorStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoDecoratorStore.java index 903709145..af072620d 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoDecoratorStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoDecoratorStore.java @@ -145,6 +145,22 @@ public void updateDecorator(String namespace, int id, String decoratorJson) thro LOG.debug("Updated decorator with ID {} in namespace '{}'", id, namespace); } + @Override + public void deleteDecorator(String namespace, int id) throws NamespaceNotFoundException, DecoratorNotFoundException { + namespaceStore.requireNamespace(namespace); + + long modified = decoratorCollection.updateOne( + Filters.eq("namespace", namespace), + Updates.pull("decorators", Filters.eq(DECORATOR_ID_FIELD, id)) + ).getModifiedCount(); + + if (modified == 0) { + throw new DecoratorNotFoundException(); + } + + LOG.debug("Deleted decorator with ID {} in namespace '{}'", id, namespace); + } + /** * Fetches the namespace document from MongoDB */ diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoFlowStore.java index 6cd01d34c..d67fe4d5f 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoFlowStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoFlowStore.java @@ -143,4 +143,12 @@ private void requireFlow(Flow flow) throws NamespaceNotFoundException, FlowNotFo throw new FlowNotFoundException(); } } + + @Override + public void deleteFlow(String namespace, int flowId) throws NamespaceNotFoundException, FlowNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, flowId)) { + throw new FlowNotFoundException(); + } + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java index c2f0f1350..e75c95cea 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java @@ -131,4 +131,12 @@ private void requireInterface(String namespace, Integer interfaceId) throws Name throw new InterfaceNotFoundException(); } } + + @Override + public void deleteInterface(String namespace, Integer interfaceId) throws NamespaceNotFoundException, InterfaceNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, interfaceId)) { + throw new InterfaceNotFoundException(); + } + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoPatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoPatternStore.java index 73c810612..35dfed3d7 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoPatternStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoPatternStore.java @@ -152,4 +152,12 @@ private void requirePattern(Pattern pattern) throws NamespaceNotFoundException, throw new PatternNotFoundException(); } } + + @Override + public void deletePattern(String namespace, int patternId) throws NamespaceNotFoundException, PatternNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, patternId)) { + throw new PatternNotFoundException(); + } + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoResourceMappingStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoResourceMappingStore.java index 0907feb3b..74ef41dd6 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoResourceMappingStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoResourceMappingStore.java @@ -198,4 +198,17 @@ public void deleteMapping(String namespace, ResourceType type, String customId) throw new MappingNotFoundException(); } } + + @Override + public void deleteMappingByNumericId(String namespace, ResourceType type, int numericId) throws NamespaceNotFoundException { + namespaceStore.requireNamespace(namespace); + + Bson filter = Filters.and( + Filters.eq("namespace", namespace), + Filters.eq("resourceType", type.name()), + Filters.eq("numericId", numericId) + ); + + mappingCollection.deleteOne(filter); + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java index b17793a56..63c221f39 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java @@ -127,4 +127,12 @@ private void requireStandard(String namespace, Integer standardId) throws Namesp throw new StandardNotFoundException(); } } + + @Override + public void deleteStandard(String namespace, Integer standardId) throws NamespaceNotFoundException, StandardNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, standardId)) { + throw new StandardNotFoundException(); + } + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoTimelineStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoTimelineStore.java index 11fb7b289..71b22b128 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoTimelineStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoTimelineStore.java @@ -151,4 +151,12 @@ private void requireTimeline(Timeline timeline) throws NamespaceNotFoundExceptio throw new TimelineNotFoundException(); } } + + @Override + public void deleteTimeline(String namespace, int timelineId) throws NamespaceNotFoundException, TimelineNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, timelineId)) { + throw new TimelineNotFoundException(); + } + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteAdrStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteAdrStore.java index afb571d95..7e0aebc3c 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteAdrStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteAdrStore.java @@ -285,4 +285,13 @@ private void requireAdr(AdrMeta adrMeta) throws NamespaceNotFoundException, AdrN throw new AdrNotFoundException(); } } + + @Override + public void deleteAdr(String namespace, int adrId) throws NamespaceNotFoundException, AdrNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, adrId)) { + throw new AdrNotFoundException(); + } + LOG.info("Deleted ADR with ID {} from namespace '{}'", adrId, namespace); + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java index b2cf1a9d2..ab7ae4977 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java @@ -196,4 +196,13 @@ private void requireArchitecture(Architecture architecture) throws NamespaceNotF namespaceStore.requireNamespace(architecture.getNamespace()); requireArchitectureExists(architecture); } + + @Override + public void deleteArchitecture(String namespace, int architectureId) throws NamespaceNotFoundException, ArchitectureNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, architectureId)) { + throw new ArchitectureNotFoundException(); + } + LOG.info("Deleted architecture with ID {} from namespace '{}'", architectureId, namespace); + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteControlStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteControlStore.java index bcbcc0e75..bbaebdd34 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteControlStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteControlStore.java @@ -12,6 +12,7 @@ import org.finos.calm.domain.exception.ControlConfigurationNotFoundException; import org.finos.calm.domain.exception.ControlConfigurationVersionExistsException; import org.finos.calm.domain.exception.ControlConfigurationVersionNotFoundException; +import org.finos.calm.domain.exception.ControlHasConfigurationsException; import org.finos.calm.domain.exception.ControlNotFoundException; import org.finos.calm.domain.exception.ControlRequirementVersionExistsException; import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; @@ -191,6 +192,41 @@ public void createConfigurationForVersion(String domain, int controlId, int conf } } + /** + * Refuses to delete a control requirement that still has configurations, rather than + * cascading — see {@link ControlHasConfigurationsException}. + * + *

Known, accepted race: same non-atomic count-then-delete as + * {@code MongoControlStore#deleteControlRequirement} — see that method's javadoc. + * {@code configurationStore} and {@code requirementStore} are two separate + * {@link NitriteVersionDocumentStore} instances, each with its own internal lock, so + * neither one's locking closes the gap between the count and the delete below.

+ */ + @Override + public void deleteControlRequirement(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException, ControlHasConfigurationsException { + requireControl(domain, controlId); + + int configurationCount = configurationStore.countHeaders(configurationNamespace(domain, controlId)); + if (configurationCount > 0) { + throw new ControlHasConfigurationsException(controlId, configurationCount); + } + + if (!requirementStore.deleteResource(domain, controlId)) { + throw new ControlNotFoundException(); + } + LOG.info("Deleted control requirement {} from domain '{}'", controlId, domain); + } + + @Override + public void deleteControlConfiguration(String domain, int controlId, int configurationId) throws DomainNotFoundException, ControlNotFoundException, ControlConfigurationNotFoundException { + requireConfiguration(domain, controlId, configurationId); + + if (!configurationStore.deleteResource(configurationNamespace(domain, controlId), configurationId)) { + throw new ControlConfigurationNotFoundException(); + } + LOG.info("Deleted configuration {} from control {} in domain '{}'", configurationId, controlId, domain); + } + private void requireControl(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException { validateDomain(domain); if (!requirementStore.headerExists(domain, controlId)) { diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteDecoratorStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteDecoratorStore.java index 0fa9e7884..89df3501c 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteDecoratorStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteDecoratorStore.java @@ -182,6 +182,38 @@ public void updateDecorator(String namespace, int id, String decoratorJson) thro LOG.debug("Updated decorator with ID {} in namespace '{}'", id, namespace); } + @Override + public void deleteDecorator(String namespace, int id) throws NamespaceNotFoundException, DecoratorNotFoundException { + namespaceStore.requireNamespace(namespace); + + lock.lock(); + try { + Document namespaceDoc = fetchNamespaceDocument(namespace); + if (namespaceDoc == null) { + throw new DecoratorNotFoundException(); + } + + TypeSafeNitriteDocument typeSafeDoc = new TypeSafeNitriteDocument<>(namespaceDoc, Document.class); + List decorators = typeSafeDoc.getList(DECORATORS_FIELD); + if (decorators == null) { + throw new DecoratorNotFoundException(); + } + + List mutableDecorators = new ArrayList<>(decorators); + boolean removed = mutableDecorators.removeIf( + decoratorEntry -> Integer.valueOf(id).equals(decoratorEntry.get(DECORATOR_ID_FIELD, Integer.class))); + if (!removed) { + throw new DecoratorNotFoundException(); + } + + namespaceDoc.put(DECORATORS_FIELD, mutableDecorators); + decoratorCollection.update(namespaceDoc); + LOG.debug("Deleted decorator with ID {} in namespace '{}'", id, namespace); + } finally { + lock.unlock(); + } + } + /** * Fetches the namespace document from NitriteDB */ diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java index 2e4dec496..ebfe0786b 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java @@ -168,4 +168,13 @@ private void requireFlow(Flow flow) throws NamespaceNotFoundException, FlowNotFo namespaceStore.requireNamespace(flow.getNamespace()); requireFlowExists(flow); } + + @Override + public void deleteFlow(String namespace, int flowId) throws NamespaceNotFoundException, FlowNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, flowId)) { + throw new FlowNotFoundException(); + } + LOG.info("Deleted flow with ID {} from namespace '{}'", flowId, namespace); + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java index ee3f1c0ad..b27b43ec4 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java @@ -157,4 +157,13 @@ private void requireInterface(String namespace, Integer interfaceId) throws Name namespaceStore.requireNamespace(namespace); requireInterfaceExists(namespace, interfaceId); } + + @Override + public void deleteInterface(String namespace, Integer interfaceId) throws NamespaceNotFoundException, InterfaceNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, interfaceId)) { + throw new InterfaceNotFoundException(); + } + LOG.info("Deleted interface with ID {} from namespace '{}'", interfaceId, namespace); + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java index d674fee70..58c1d644d 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java @@ -186,4 +186,13 @@ private void requirePattern(Pattern pattern) throws NamespaceNotFoundException, namespaceStore.requireNamespace(pattern.getNamespace()); requirePatternExists(pattern); } + + @Override + public void deletePattern(String namespace, int patternId) throws NamespaceNotFoundException, PatternNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, patternId)) { + throw new PatternNotFoundException(); + } + LOG.info("Deleted pattern with ID {} from namespace '{}'", patternId, namespace); + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteResourceMappingStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteResourceMappingStore.java index cd704b7e1..c9716fba7 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteResourceMappingStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteResourceMappingStore.java @@ -215,4 +215,22 @@ public void deleteMapping(String namespace, ResourceType type, String customId) lock.unlock(); } } + + @Override + public void deleteMappingByNumericId(String namespace, ResourceType type, int numericId) throws NamespaceNotFoundException { + namespaceStore.requireNamespace(namespace); + + lock.lock(); + try { + Filter filter = Filter.and(where(NAMESPACE_FIELD).eq(namespace), + where(RESOURCE_TYPE_FIELD).eq(type.name()), + where(NUMERIC_ID_FIELD).eq(numericId)); + Document existing = mappingCollection.find(filter).firstOrNull(); + if (existing != null) { + mappingCollection.remove(existing); + } + } finally { + lock.unlock(); + } + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java index 84d6420bf..37e3cb3cc 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java @@ -153,4 +153,13 @@ private void requireStandard(String namespace, Integer standardId) throws Namesp namespaceStore.requireNamespace(namespace); requireStandardExists(namespace, standardId); } + + @Override + public void deleteStandard(String namespace, Integer standardId) throws NamespaceNotFoundException, StandardNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, standardId)) { + throw new StandardNotFoundException(); + } + LOG.info("Deleted standard with ID {} from namespace '{}'", standardId, namespace); + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteTimelineStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteTimelineStore.java index 352b9f634..0782123d0 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteTimelineStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteTimelineStore.java @@ -181,4 +181,13 @@ private void requireTimeline(Timeline timeline) throws NamespaceNotFoundExceptio namespaceStore.requireNamespace(timeline.getNamespace()); requireTimelineExists(timeline); } + + @Override + public void deleteTimeline(String namespace, int timelineId) throws NamespaceNotFoundException, TimelineNotFoundException { + namespaceStore.requireNamespace(namespace); + if (!documentStore.deleteResource(namespace, timelineId)) { + throw new TimelineNotFoundException(); + } + LOG.info("Deleted timeline with ID {} from namespace '{}'", timelineId, namespace); + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/util/MongoVersionDocumentStore.java b/calm-hub/src/main/java/org/finos/calm/store/util/MongoVersionDocumentStore.java index c042d1104..91ea2fa6f 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/util/MongoVersionDocumentStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/util/MongoVersionDocumentStore.java @@ -10,6 +10,7 @@ import com.mongodb.client.model.Sorts; import com.mongodb.client.model.UpdateOptions; import com.mongodb.client.model.Updates; +import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; import org.bson.Document; import org.bson.conversions.Bson; @@ -156,8 +157,9 @@ public void createHeader(String namespace, int resourceId, String name, String d * *

The old shape pushed the resource and its first version in one document write, so * a failure left nothing behind. Splitting them means a failed version write can strand - * a header that no API can remove — there is no delete endpoint for these types — and it - * would show up in listings and search with {@code versionCount: 0} forever.

+ * a header that no API can remove — this is a narrower operation than + * {@link #deleteResource}, the general-purpose delete — and it would show up in listings + * and search with {@code versionCount: 0} forever.

* *

Not a general-purpose delete: nothing else calls this, and it deliberately does not * touch the version collection, because the only caller has just failed to write the @@ -175,6 +177,31 @@ public void deleteHeader(String namespace, int resourceId) { } } + /** + * Deletes a resource entirely: its header and every version document. This is the + * general-purpose delete backing a DELETE endpoint — unlike {@link #deleteHeader}, a + * failure here is translated and thrown rather than swallowed, and the version + * collection is cleared too. + * + *

Versions are removed before the header, not after: if the version delete fails, + * the header is still there as evidence the resource exists, rather than leaving + * unreachable version documents behind under a header that's already gone.

+ * + * @return {@code true} if a header was actually deleted, {@code false} if none existed + * at this (namespace, resourceId) — lets the caller distinguish "already gone" from + * "removed". + */ + public boolean deleteResource(String namespace, int resourceId) { + try { + versionCollection.deleteMany(headerFilter(namespace, resourceId)); + DeleteResult result = headerCollection.deleteOne(headerFilter(namespace, resourceId)); + return result.getDeletedCount() > 0; + } catch (MongoException e) { + LOG.error("Failed to delete resource [namespace={}, {}={}]", namespace, idField, resourceId, e); + throw StorageWriteException.writeFailed(e); + } + } + /** * Writes the first version of a newly created resource, removing the header again if * that fails, so a half-created resource never survives the request. diff --git a/calm-hub/src/main/java/org/finos/calm/store/util/NitriteVersionDocumentStore.java b/calm-hub/src/main/java/org/finos/calm/store/util/NitriteVersionDocumentStore.java index 44234f116..3f7d8a89f 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/util/NitriteVersionDocumentStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/util/NitriteVersionDocumentStore.java @@ -156,6 +156,38 @@ public void deleteHeader(String namespace, int resourceId) { } } + /** + * Deletes a resource entirely: its header and every version document, under a single + * write lock so no concurrent read observes a header with no versions or vice versa. + * See {@link MongoVersionDocumentStore#deleteResource} — this is the general-purpose + * delete backing a DELETE endpoint, unlike {@link #deleteHeader}. + * + * @return {@code true} if a header was actually removed, {@code false} if none existed. + */ + public boolean deleteResource(String namespace, int resourceId) { + lock.writeLock().lock(); + try { + Filter filter = headerFilter(namespace, resourceId); + Document header = headerCollection.find(filter).firstOrNull(); + if (header == null) { + return false; + } + // Materialize matches before removing — mutating the collection while its + // cursor is still being iterated is unsafe. + List versions = new ArrayList<>(); + for (Document versionDocument : versionCollection.find(filter)) { + versions.add(versionDocument); + } + for (Document versionDocument : versions) { + versionCollection.remove(versionDocument); + } + headerCollection.remove(header); + return true; + } finally { + lock.writeLock().unlock(); + } + } + /** * Writes the first version of a newly created resource, removing the header again if * that fails. See {@link MongoVersionDocumentStore#createFirstVersion} — the reasoning diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestAdrResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestAdrResourceShould.java index f01a33301..ccd399b77 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestAdrResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestAdrResourceShould.java @@ -393,4 +393,27 @@ private void verifyExpectedGetAdr(String namespace) throws NamespaceNotFoundExce verify(mockAdrStore, times(1)).getAdr(expectedAdrToRetrieveMeta); } + static Stream provideParametersForDeleteAdrTests() { + return Stream.of( + Arguments.of("invalid", new NamespaceNotFoundException(), 404), + Arguments.of("valid", new AdrNotFoundException(), 404), + Arguments.of("valid", null, 204) + ); + } + + @ParameterizedTest + @MethodSource("provideParametersForDeleteAdrTests") + void respond_correctly_to_delete_adr(String namespace, Throwable exceptionToThrow, int expectedStatusCode) throws NamespaceNotFoundException, AdrNotFoundException { + if (exceptionToThrow != null) { + doThrow(exceptionToThrow).when(mockAdrStore).deleteAdr(namespace, 12); + } + + given() + .when() + .delete("/api/calm/namespaces/" + namespace + "/adrs/12") + .then() + .statusCode(expectedStatusCode); + + verify(mockAdrStore, times(1)).deleteAdr(namespace, 12); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java index 78a3ad1a1..837302150 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java @@ -7,6 +7,7 @@ import io.quarkus.test.security.TestSecurity; import org.bson.json.JsonParseException; import org.finos.calm.domain.Architecture; +import org.finos.calm.domain.ResourceType; import org.finos.calm.domain.architecture.ArchitectureRequest; import org.finos.calm.domain.namespaces.NamespaceResourceSummary; import org.finos.calm.domain.exception.ArchitectureNotFoundException; @@ -485,6 +486,64 @@ void return_a_400_when_an_invalid_format_of_namespace_is_provided_on_get_archite .body(containsString(NAMESPACE_MESSAGE)); } + @Test + void return_a_400_when_an_invalid_format_of_namespace_is_provided_on_delete_architecture() { + given() + .when() + .delete("/api/calm/namespaces/fin_os/architectures/12") + .then() + .statusCode(400) + .body(containsString(NAMESPACE_MESSAGE)); + } + + static Stream provideParametersForDeleteArchitectureTests() { + return Stream.of( + Arguments.of("invalid", new NamespaceNotFoundException(), 404), + Arguments.of("valid", new ArchitectureNotFoundException(), 404), + Arguments.of("valid", null, 204) + ); + } + + @ParameterizedTest + @MethodSource("provideParametersForDeleteArchitectureTests") + void respond_correctly_to_delete_architecture(String namespace, Throwable exceptionToThrow, int expectedStatusCode) throws ArchitectureNotFoundException, NamespaceNotFoundException { + if (exceptionToThrow != null) { + doThrow(exceptionToThrow).when(mockArchitectureStore).deleteArchitecture(namespace, 12); + } + + given() + .when() + .delete("/api/calm/namespaces/" + namespace + "/architectures/12") + .then() + .statusCode(expectedStatusCode); + + verify(mockArchitectureStore, times(1)).deleteArchitecture(namespace, 12); + } + + @Test + void delete_architecture_also_cleans_up_its_resource_mapping() throws Exception { + given() + .when() + .delete("/api/calm/namespaces/valid/architectures/12") + .then() + .statusCode(204); + + verify(mockResourceMappingStore, times(1)).deleteMappingByNumericId("valid", ResourceType.ARCHITECTURE, 12); + } + + @Test + void not_clean_up_the_resource_mapping_when_deleting_a_missing_architecture() throws Exception { + doThrow(new ArchitectureNotFoundException()).when(mockArchitectureStore).deleteArchitecture("valid", 12); + + given() + .when() + .delete("/api/calm/namespaces/valid/architectures/12") + .then() + .statusCode(404); + + verifyNoInteractions(mockResourceMappingStore); + } + @Test void return_an_implied_timeline_projection_when_architecture_has_versions() throws NamespaceNotFoundException, ArchitectureNotFoundException { when(mockTimelineStore.getTimelinesForNamespace(any())).thenReturn(List.of()); diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java index c440a8a3b..2e8e39934 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java @@ -620,4 +620,74 @@ void respond_correctly_to_create_configuration_version(Throwable exceptionToThro .statusCode(expectedStatusCode); } } + + @Test + void return_a_400_when_an_invalid_format_of_domain_is_provided_on_delete_control_requirement() { + given() + .when() + .delete("/api/calm/domains/invalid_domain/controls/1") + .then() + .statusCode(400) + .body(containsString(DOMAIN_MESSAGE)); + } + + static Stream provideParametersForDeleteControlRequirementTests() { + return Stream.of( + Arguments.of(new DomainNotFoundException(INVALID_DOMAIN), 404), + Arguments.of(new ControlNotFoundException(), 404), + Arguments.of(new ControlHasConfigurationsException(1, 2), 409), + Arguments.of(null, 204) + ); + } + + @ParameterizedTest + @MethodSource("provideParametersForDeleteControlRequirementTests") + void respond_correctly_to_delete_control_requirement(Throwable exceptionToThrow, int expectedStatusCode) throws Exception { + if (exceptionToThrow != null) { + doThrow(exceptionToThrow).when(mockControlStore).deleteControlRequirement(VALID_DOMAIN, 1); + } + + given() + .when() + .delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/1") + .then() + .statusCode(expectedStatusCode); + + verify(mockControlStore, times(1)).deleteControlRequirement(VALID_DOMAIN, 1); + } + + @Test + void return_a_400_when_an_invalid_format_of_domain_is_provided_on_delete_control_configuration() { + given() + .when() + .delete("/api/calm/domains/invalid_domain/controls/1/configurations/10") + .then() + .statusCode(400) + .body(containsString(DOMAIN_MESSAGE)); + } + + static Stream provideParametersForDeleteControlConfigurationTests() { + return Stream.of( + Arguments.of(new DomainNotFoundException(INVALID_DOMAIN), 404), + Arguments.of(new ControlNotFoundException(), 404), + Arguments.of(new ControlConfigurationNotFoundException(), 404), + Arguments.of(null, 204) + ); + } + + @ParameterizedTest + @MethodSource("provideParametersForDeleteControlConfigurationTests") + void respond_correctly_to_delete_control_configuration(Throwable exceptionToThrow, int expectedStatusCode) throws Exception { + if (exceptionToThrow != null) { + doThrow(exceptionToThrow).when(mockControlStore).deleteControlConfiguration(VALID_DOMAIN, 1, 10); + } + + given() + .when() + .delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/configurations/10") + .then() + .statusCode(expectedStatusCode); + + verify(mockControlStore, times(1)).deleteControlConfiguration(VALID_DOMAIN, 1, 10); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestDecoratorResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestDecoratorResourceShould.java index aaf617c2e..90456200e 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestDecoratorResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestDecoratorResourceShould.java @@ -676,4 +676,62 @@ void return_400_when_put_id_is_negative() { .statusCode(400) .body(containsString("ID must be a positive integer")); } + + // ---- DELETE /api/calm/namespaces/{namespace}/decorators/{id} ---- + + @Test + void return_204_when_decorator_deleted_successfully() throws Exception { + given() + .when() + .delete("/api/calm/namespaces/finos/decorators/1") + .then() + .statusCode(204); + + verify(decoratorStore, times(1)).deleteDecorator("finos", 1); + } + + @Test + void return_404_when_decorator_not_found_for_delete() throws Exception { + doThrow(new DecoratorNotFoundException()) + .when(decoratorStore).deleteDecorator(anyString(), anyInt()); + + given() + .when() + .delete("/api/calm/namespaces/finos/decorators/999") + .then() + .statusCode(404) + .body(containsString("Decorator with ID 999 does not exist in namespace: finos")); + } + + @Test + void return_404_when_namespace_does_not_exist_for_delete_decorator() throws Exception { + doThrow(new NamespaceNotFoundException()) + .when(decoratorStore).deleteDecorator(anyString(), anyInt()); + + given() + .when() + .delete("/api/calm/namespaces/invalid-namespace/decorators/1") + .then() + .statusCode(404); + } + + @Test + void return_400_when_namespace_has_invalid_characters_for_delete_decorator() { + given() + .when() + .delete("/api/calm/namespaces/invalid@namespace/decorators/1") + .then() + .statusCode(400) + .body(containsString("namespace must match pattern")); + } + + @Test + void return_400_when_delete_id_is_zero() { + given() + .when() + .delete("/api/calm/namespaces/test-namespace/decorators/0") + .then() + .statusCode(400) + .body(containsString("ID must be a positive integer")); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java index 2dc4a522d..45e11d488 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java @@ -5,6 +5,7 @@ import io.quarkus.test.security.TestSecurity; import org.bson.json.JsonParseException; import org.finos.calm.domain.Flow; +import org.finos.calm.domain.ResourceType; import org.finos.calm.domain.exception.FlowNotFoundException; import org.finos.calm.domain.exception.FlowVersionExistsException; import org.finos.calm.domain.exception.FlowVersionNotFoundException; @@ -23,6 +24,7 @@ import java.util.stream.Stream; import static io.restassured.RestAssured.given; +import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; @@ -371,4 +373,62 @@ void return_forbidden_for_put_operations_on_flows_default_and_when_configured() .then() .statusCode(403); } + + @Test + void return_a_400_when_an_invalid_format_of_namespace_is_provided_on_delete_flow() { + given() + .when() + .delete("/api/calm/namespaces/fin_os/flows/12") + .then() + .statusCode(400) + .body(containsString(NAMESPACE_MESSAGE)); + } + + static Stream provideParametersForDeleteFlowTests() { + return Stream.of( + Arguments.of("invalid", new NamespaceNotFoundException(), 404), + Arguments.of("valid", new FlowNotFoundException(), 404), + Arguments.of("valid", null, 204) + ); + } + + @ParameterizedTest + @MethodSource("provideParametersForDeleteFlowTests") + void respond_correctly_to_delete_flow(String namespace, Throwable exceptionToThrow, int expectedStatusCode) throws FlowNotFoundException, NamespaceNotFoundException { + if (exceptionToThrow != null) { + doThrow(exceptionToThrow).when(mockFlowStore).deleteFlow(namespace, 12); + } + + given() + .when() + .delete("/api/calm/namespaces/" + namespace + "/flows/12") + .then() + .statusCode(expectedStatusCode); + + verify(mockFlowStore, times(1)).deleteFlow(namespace, 12); + } + + @Test + void delete_flow_also_cleans_up_its_resource_mapping() throws Exception { + given() + .when() + .delete("/api/calm/namespaces/valid/flows/12") + .then() + .statusCode(204); + + verify(mockResourceMappingStore, times(1)).deleteMappingByNumericId("valid", ResourceType.FLOW, 12); + } + + @Test + void not_clean_up_the_resource_mapping_when_deleting_a_missing_flow() throws Exception { + doThrow(new FlowNotFoundException()).when(mockFlowStore).deleteFlow("valid", 12); + + given() + .when() + .delete("/api/calm/namespaces/valid/flows/12") + .then() + .statusCode(404); + + verifyNoInteractions(mockResourceMappingStore); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java index f92c7cc55..34e11e8ec 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java @@ -393,4 +393,62 @@ void respond_correctly_to_create_interfaces(String namespace, Throwable exceptio verify(mockInterfaceStore, times(1)).createInterfaceForVersion(createInterfaceRequest, namespace, 5, "1.0.1"); } + + @Test + void return_a_400_when_an_invalid_format_of_namespace_is_provided_on_delete_interface() { + given() + .when() + .delete("/api/calm/namespaces/fin_os/interfaces/12") + .then() + .statusCode(400) + .body(containsString(NAMESPACE_MESSAGE)); + } + + static Stream provideParametersForDeleteInterfaceTests() { + return Stream.of( + Arguments.of("invalid", new NamespaceNotFoundException(), 404), + Arguments.of("valid", new InterfaceNotFoundException(), 404), + Arguments.of("valid", null, 204) + ); + } + + @ParameterizedTest + @MethodSource("provideParametersForDeleteInterfaceTests") + void respond_correctly_to_delete_interface(String namespace, Throwable exceptionToThrow, int expectedStatusCode) throws InterfaceNotFoundException, NamespaceNotFoundException { + if (exceptionToThrow != null) { + doThrow(exceptionToThrow).when(mockInterfaceStore).deleteInterface(namespace, 12); + } + + given() + .when() + .delete("/api/calm/namespaces/" + namespace + "/interfaces/12") + .then() + .statusCode(expectedStatusCode); + + verify(mockInterfaceStore, times(1)).deleteInterface(namespace, 12); + } + + @Test + void delete_interface_also_cleans_up_its_resource_mapping() throws Exception { + given() + .when() + .delete("/api/calm/namespaces/valid/interfaces/12") + .then() + .statusCode(204); + + verify(mockResourceMappingStore, times(1)).deleteMappingByNumericId("valid", ResourceType.INTERFACE, 12); + } + + @Test + void not_clean_up_the_resource_mapping_when_deleting_a_missing_interface() throws Exception { + doThrow(new InterfaceNotFoundException()).when(mockInterfaceStore).deleteInterface("valid", 12); + + given() + .when() + .delete("/api/calm/namespaces/valid/interfaces/12") + .then() + .statusCode(404); + + verifyNoInteractions(mockResourceMappingStore); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingCleanupShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingCleanupShould.java new file mode 100644 index 000000000..4ab29650e --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingCleanupShould.java @@ -0,0 +1,57 @@ +package org.finos.calm.resources; + +import org.finos.calm.domain.ResourceType; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.store.ResourceMappingStore; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.Logger; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class TestMappingCleanupShould { + + @Mock + private ResourceMappingStore mappingStore; + + @Mock + private Logger logger; + + @Test + void delete_the_mapping_by_numeric_id() throws NamespaceNotFoundException { + MappingCleanup.deleteMapping(mappingStore, logger, "finos", ResourceType.ARCHITECTURE, 42); + + verify(mappingStore).deleteMappingByNumericId("finos", ResourceType.ARCHITECTURE, 42); + } + + @Test + void log_rather_than_throw_when_the_namespace_has_vanished_mid_request() throws NamespaceNotFoundException { + // The resource is already deleted by the time this runs, so a failure to clean up its + // mapping must not fail the delete response over cleanup that can't be undone anyway. + doThrow(new NamespaceNotFoundException()) + .when(mappingStore).deleteMappingByNumericId("finos", ResourceType.ARCHITECTURE, 42); + + assertDoesNotThrow(() -> + MappingCleanup.deleteMapping(mappingStore, logger, "finos", ResourceType.ARCHITECTURE, 42)); + + verify(mappingStore).deleteMappingByNumericId("finos", ResourceType.ARCHITECTURE, 42); + } + + @Test + void log_rather_than_throw_when_the_mapping_store_fails_at_runtime() throws NamespaceNotFoundException { + // A driver/DB failure (MongoException, a Nitrite lock/store error, ...) must not + // surface as an unhandled 500 for a delete that has already succeeded. + doThrow(new RuntimeException("connection reset")) + .when(mappingStore).deleteMappingByNumericId("finos", ResourceType.ARCHITECTURE, 42); + + assertDoesNotThrow(() -> + MappingCleanup.deleteMapping(mappingStore, logger, "finos", ResourceType.ARCHITECTURE, 42)); + + verify(mappingStore).deleteMappingByNumericId("finos", ResourceType.ARCHITECTURE, 42); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java index c2d8eac17..4dba94e64 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java @@ -474,4 +474,62 @@ void return_forbidden_for_put_operations_on_patterns_by_default_and_when_configu .then() .statusCode(403); } + + @Test + void return_a_400_when_an_invalid_format_of_namespace_is_provided_on_delete_pattern() { + given() + .when() + .delete("/api/calm/namespaces/fin_os/patterns/12") + .then() + .statusCode(400) + .body(containsString(NAMESPACE_MESSAGE)); + } + + static Stream provideParametersForDeletePatternTests() { + return Stream.of( + Arguments.of("invalid", new NamespaceNotFoundException(), 404), + Arguments.of("valid", new PatternNotFoundException(), 404), + Arguments.of("valid", null, 204) + ); + } + + @ParameterizedTest + @MethodSource("provideParametersForDeletePatternTests") + void respond_correctly_to_delete_pattern(String namespace, Throwable exceptionToThrow, int expectedStatusCode) throws PatternNotFoundException, NamespaceNotFoundException { + if (exceptionToThrow != null) { + doThrow(exceptionToThrow).when(mockPatternStore).deletePattern(namespace, 12); + } + + given() + .when() + .delete("/api/calm/namespaces/" + namespace + "/patterns/12") + .then() + .statusCode(expectedStatusCode); + + verify(mockPatternStore, times(1)).deletePattern(namespace, 12); + } + + @Test + void delete_pattern_also_cleans_up_its_resource_mapping() throws Exception { + given() + .when() + .delete("/api/calm/namespaces/valid/patterns/12") + .then() + .statusCode(204); + + verify(mockResourceMappingStore, times(1)).deleteMappingByNumericId("valid", ResourceType.PATTERN, 12); + } + + @Test + void not_clean_up_the_resource_mapping_when_deleting_a_missing_pattern() throws Exception { + doThrow(new PatternNotFoundException()).when(mockPatternStore).deletePattern("valid", 12); + + given() + .when() + .delete("/api/calm/namespaces/valid/patterns/12") + .then() + .statusCode(404); + + verifyNoInteractions(mockResourceMappingStore); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java index 7ecfd924a..7efb1c5c5 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java @@ -5,6 +5,7 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; +import org.finos.calm.domain.ResourceType; import org.finos.calm.domain.Standard; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.exception.StandardNotFoundException; @@ -349,4 +350,62 @@ void respond_correctly_to_create_standards(String namespace, Throwable exception verify(mockStandardStore, times(1)).createStandardForVersion(createStandardRequest, namespace, 5, "1.0.1"); } + + @Test + void return_a_400_when_an_invalid_format_of_namespace_is_provided_on_delete_standard() { + given() + .when() + .delete("/api/calm/namespaces/fin_os/standards/12") + .then() + .statusCode(400) + .body(containsString(NAMESPACE_MESSAGE)); + } + + static Stream provideParametersForDeleteStandardTests() { + return Stream.of( + Arguments.of("invalid", new NamespaceNotFoundException(), 404), + Arguments.of("valid", new StandardNotFoundException(), 404), + Arguments.of("valid", null, 204) + ); + } + + @ParameterizedTest + @MethodSource("provideParametersForDeleteStandardTests") + void respond_correctly_to_delete_standard(String namespace, Throwable exceptionToThrow, int expectedStatusCode) throws StandardNotFoundException, NamespaceNotFoundException { + if (exceptionToThrow != null) { + doThrow(exceptionToThrow).when(mockStandardStore).deleteStandard(namespace, 12); + } + + given() + .when() + .delete("/api/calm/namespaces/" + namespace + "/standards/12") + .then() + .statusCode(expectedStatusCode); + + verify(mockStandardStore, times(1)).deleteStandard(namespace, 12); + } + + @Test + void delete_standard_also_cleans_up_its_resource_mapping() throws Exception { + given() + .when() + .delete("/api/calm/namespaces/valid/standards/12") + .then() + .statusCode(204); + + verify(mockResourceMappingStore, times(1)).deleteMappingByNumericId("valid", ResourceType.STANDARD, 12); + } + + @Test + void not_clean_up_the_resource_mapping_when_deleting_a_missing_standard() throws Exception { + doThrow(new StandardNotFoundException()).when(mockStandardStore).deleteStandard("valid", 12); + + given() + .when() + .delete("/api/calm/namespaces/valid/standards/12") + .then() + .statusCode(404); + + verifyNoInteractions(mockResourceMappingStore); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java index 747ea5c64..e3630f748 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java @@ -326,4 +326,38 @@ void return_forbidden_for_put_operations_on_timelines_by_default() { .then() .statusCode(403); } + + @Test + void return_a_400_when_an_invalid_format_of_namespace_is_provided_on_delete_timeline() { + given() + .when() + .delete("/api/calm/namespaces/fin_os/timelines/12") + .then() + .statusCode(400) + .body(containsString(NAMESPACE_MESSAGE)); + } + + static Stream provideParametersForDeleteTimelineTests() { + return Stream.of( + Arguments.of("invalid", new NamespaceNotFoundException(), 404), + Arguments.of("valid", new TimelineNotFoundException(), 404), + Arguments.of("valid", null, 204) + ); + } + + @ParameterizedTest + @MethodSource("provideParametersForDeleteTimelineTests") + void respond_correctly_to_delete_timeline(String namespace, Throwable exceptionToThrow, int expectedStatusCode) throws TimelineNotFoundException, NamespaceNotFoundException { + if (exceptionToThrow != null) { + doThrow(exceptionToThrow).when(mockTimelineStore).deleteTimeline(namespace, 12); + } + + given() + .when() + .delete("/api/calm/namespaces/" + namespace + "/timelines/12") + .then() + .statusCode(expectedStatusCode); + + verify(mockTimelineStore, times(1)).deleteTimeline(namespace, 12); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/security/TestAuditRequestFilterShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestAuditRequestFilterShould.java index 4b9e62583..4e1ad1106 100644 --- a/calm-hub/src/test/java/org/finos/calm/security/TestAuditRequestFilterShould.java +++ b/calm-hub/src/test/java/org/finos/calm/security/TestAuditRequestFilterShould.java @@ -429,6 +429,45 @@ void resolve_control_requirement_version_directly_from_path_params() { assertThat(entry.getAction(), is(AuditAction.UPDATE)); } + @Test + void resolve_control_requirement_deletion_directly_from_path_params() { + when(resourceInfo.getResourceClass()).thenReturn((Class) ControlResource.class); + when(resourceInfo.getResourceMethod()).thenReturn(mockMethod(ControlResource.class, "deleteControlRequirement")); + MultivaluedMap pathParams = new MultivaluedHashMap<>(); + pathParams.putSingle("domain", "payments"); + pathParams.putSingle("controlId", "11"); + ContainerRequestContext requestContext = mockRequest("DELETE", pathParams); + ContainerResponseContext responseContext = mockResponse(204, null); + + filter.filter(requestContext, responseContext); + + AuditLogEntry entry = captureRecordedEntry(); + assertThat(entry.getEntityType(), is(AuditEntityType.CONTROL_REQUIREMENT)); + assertThat(entry.getDomain(), is("payments")); + assertThat(entry.getEntityId(), is("11")); + assertThat(entry.getAction(), is(AuditAction.DELETE)); + } + + @Test + void resolve_control_configuration_deletion_directly_from_path_params() { + when(resourceInfo.getResourceClass()).thenReturn((Class) ControlResource.class); + when(resourceInfo.getResourceMethod()).thenReturn(mockMethod(ControlResource.class, "deleteControlConfiguration")); + MultivaluedMap pathParams = new MultivaluedHashMap<>(); + pathParams.putSingle("domain", "payments"); + pathParams.putSingle("controlId", "11"); + pathParams.putSingle("configId", "3"); + ContainerRequestContext requestContext = mockRequest("DELETE", pathParams); + ContainerResponseContext responseContext = mockResponse(204, null); + + filter.filter(requestContext, responseContext); + + AuditLogEntry entry = captureRecordedEntry(); + assertThat(entry.getEntityType(), is(AuditEntityType.CONTROL_CONFIGURATION)); + assertThat(entry.getDomain(), is("payments")); + assertThat(entry.getEntityId(), is("3")); + assertThat(entry.getAction(), is(AuditAction.DELETE)); + } + // --- MappingControllerResource dispatch -------------------------------------- @Test diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoAdrStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoAdrStoreShould.java index 385b65bc6..6021b7314 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoAdrStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoAdrStoreShould.java @@ -8,6 +8,7 @@ import com.mongodb.client.FindIterable; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; +import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; @@ -455,4 +456,30 @@ void report_the_revision_already_exists_when_two_writers_race() throws Exception assertThrows(AdrRevisionExistsException.class, () -> store.updateAdrStatus(adrMeta(1, null), Status.accepted)); } + + // --- deleteAdr --- + + @Test + void throw_a_namespace_exception_when_deleting_an_adr_in_a_missing_namespace() { + when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deleteAdr(NAMESPACE, ADR_ID)); + } + + @Test + void delete_the_header_and_all_revisions_when_the_adr_exists() throws Exception { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1)); + + store.deleteAdr(NAMESPACE, ADR_ID); + + verify(versionCollection).deleteMany(any(Bson.class)); + verify(headerCollection).deleteOne(any(Bson.class)); + } + + @Test + void throw_an_adr_exception_when_deleting_a_missing_adr() { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0)); + + assertThrows(AdrNotFoundException.class, () -> store.deleteAdr(NAMESPACE, ADR_ID)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoArchitectureStoreShould.java index 227913340..9e32c04f7 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoArchitectureStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoArchitectureStoreShould.java @@ -7,6 +7,7 @@ import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; import com.mongodb.client.model.UpdateOptions; +import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; @@ -431,6 +432,32 @@ void report_a_plain_write_failure_for_other_version_write_errors() { assertThat(exception.isCapacityExceeded(), is(false)); } + // --- deleteArchitecture --- + + @Test + void throw_a_namespace_exception_when_deleting_an_architecture_in_a_missing_namespace() { + when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deleteArchitecture(NAMESPACE, ARCHITECTURE_ID)); + } + + @Test + void delete_the_header_and_all_versions_when_the_architecture_exists() throws Exception { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1)); + + store.deleteArchitecture(NAMESPACE, ARCHITECTURE_ID); + + verify(versionCollection).deleteMany(any(Bson.class)); + verify(headerCollection).deleteOne(any(Bson.class)); + } + + @Test + void throw_an_architecture_exception_when_deleting_a_missing_architecture() { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0)); + + assertThrows(ArchitectureNotFoundException.class, () -> store.deleteArchitecture(NAMESPACE, ARCHITECTURE_ID)); + } + @Test void page_the_summary_window_at_the_database() throws NamespaceNotFoundException { FindIterable iterable = stubFind(headerCollection, List.of()); diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoControlStoreShould.java index 027010ed5..7c5555995 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoControlStoreShould.java @@ -6,6 +6,7 @@ import com.mongodb.client.FindIterable; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; +import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; @@ -19,6 +20,7 @@ import org.finos.calm.domain.exception.ControlConfigurationNotFoundException; import org.finos.calm.domain.exception.ControlConfigurationVersionExistsException; import org.finos.calm.domain.exception.ControlConfigurationVersionNotFoundException; +import org.finos.calm.domain.exception.ControlHasConfigurationsException; import org.finos.calm.domain.exception.ControlNotFoundException; import org.finos.calm.domain.exception.ControlRequirementVersionExistsException; import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; @@ -38,6 +40,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -581,4 +584,78 @@ void create_configuration_for_version_never_syncs_name_or_description_onto_the_h verify(configHeaders, Mockito.times(1)).updateOne(any(Bson.class), any(Bson.class)); } + + // --- deleteControlRequirement --- + + @Test + void throw_a_domain_exception_when_deleting_a_control_requirement_in_a_missing_domain() { + assertThrows(DomainNotFoundException.class, () -> store.deleteControlRequirement("invalid", CONTROL_ID)); + } + + @Test + void throw_a_control_exception_when_deleting_a_missing_control_requirement() { + controlDoesNotExist(); + + assertThrows(ControlNotFoundException.class, () -> store.deleteControlRequirement(DOMAIN, CONTROL_ID)); + } + + @Test + void refuse_to_delete_a_control_requirement_that_still_has_configurations() { + controlExists(); + when(configHeaders.countDocuments(any(Bson.class))).thenReturn(2L); + + ControlHasConfigurationsException exception = assertThrows(ControlHasConfigurationsException.class, + () -> store.deleteControlRequirement(DOMAIN, CONTROL_ID)); + assertThat(exception.getControlId(), is(CONTROL_ID)); + assertThat(exception.getConfigurationCount(), is(2)); + verify(controlHeaders, never()).deleteOne(any(Bson.class)); + } + + @Test + void delete_the_requirement_header_and_all_versions_when_it_has_no_configurations() throws Exception { + controlExists(); + when(configHeaders.countDocuments(any(Bson.class))).thenReturn(0L); + when(controlHeaders.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1)); + + store.deleteControlRequirement(DOMAIN, CONTROL_ID); + + verify(controlVersions).deleteMany(any(Bson.class)); + verify(controlHeaders).deleteOne(any(Bson.class)); + } + + // --- deleteControlConfiguration --- + + @Test + void throw_a_domain_exception_when_deleting_a_configuration_in_a_missing_domain() { + assertThrows(DomainNotFoundException.class, + () -> store.deleteControlConfiguration("invalid", CONTROL_ID, CONFIGURATION_ID)); + } + + @Test + void throw_a_control_exception_when_deleting_a_configuration_for_a_missing_control() { + controlDoesNotExist(); + + assertThrows(ControlNotFoundException.class, + () -> store.deleteControlConfiguration(DOMAIN, CONTROL_ID, CONFIGURATION_ID)); + } + + @Test + void throw_a_configuration_exception_when_deleting_a_missing_configuration() { + controlExists(); + stubFind(configHeaders, List.of()); + + assertThrows(ControlConfigurationNotFoundException.class, + () -> store.deleteControlConfiguration(DOMAIN, CONTROL_ID, CONFIGURATION_ID)); + } + + @Test + void delete_the_configuration_header_and_all_versions_when_it_exists() throws Exception { + configurationExists(); + when(configHeaders.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1)); + + store.deleteControlConfiguration(DOMAIN, CONTROL_ID, CONFIGURATION_ID); + + verify(configVersions).deleteMany(any(Bson.class)); + verify(configHeaders).deleteOne(any(Bson.class)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoDecoratorStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoDecoratorStoreShould.java index 31ba3de82..85f9c0452 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoDecoratorStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoDecoratorStoreShould.java @@ -728,4 +728,58 @@ void should_throw_namespace_not_found_when_updating_decorator_in_unknown_namespa verify(namespaceStore).namespaceExists(namespace); verify(decoratorCollection, never()).updateOne(any(Bson.class), any(Bson.class)); } + + @Test + void should_delete_decorator_successfully() throws Exception { + // Given + String namespace = "finos"; + int decoratorId = 1; + + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + UpdateResult updateResult = mock(UpdateResult.class); + when(updateResult.getModifiedCount()).thenReturn(1L); + when(decoratorCollection.updateOne(any(Bson.class), any(Bson.class))).thenReturn(updateResult); + + // When + decoratorStore.deleteDecorator(namespace, decoratorId); + + // Then + verify(namespaceStore).namespaceExists(namespace); + verify(decoratorCollection).updateOne(any(Bson.class), any(Bson.class)); + } + + @Test + void should_throw_decorator_not_found_when_delete_matches_nothing() { + // Given + String namespace = "finos"; + int decoratorId = 99; + + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + UpdateResult updateResult = mock(UpdateResult.class); + when(updateResult.getModifiedCount()).thenReturn(0L); + when(decoratorCollection.updateOne(any(Bson.class), any(Bson.class))).thenReturn(updateResult); + + // When & Then + assertThrows(DecoratorNotFoundException.class, + () -> decoratorStore.deleteDecorator(namespace, decoratorId)); + + verify(namespaceStore).namespaceExists(namespace); + verify(decoratorCollection).updateOne(any(Bson.class), any(Bson.class)); + } + + @Test + void should_throw_namespace_not_found_when_deleting_decorator_in_unknown_namespace() { + // Given + String namespace = "unknown-namespace"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(false); + + // When & Then + assertThrows(NamespaceNotFoundException.class, + () -> decoratorStore.deleteDecorator(namespace, 1)); + + verify(namespaceStore).namespaceExists(namespace); + verify(decoratorCollection, never()).updateOne(any(Bson.class), any(Bson.class)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoFlowStoreShould.java index a66401bca..0db7f214d 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoFlowStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoFlowStoreShould.java @@ -7,6 +7,7 @@ import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; import com.mongodb.client.model.UpdateOptions; +import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; @@ -412,4 +413,30 @@ void report_a_plain_write_failure_for_other_version_write_errors() { () -> store.updateFlowForVersion(flow("1.0.1"))); assertThat(exception.isCapacityExceeded(), is(false)); } + + // --- deleteFlow --- + + @Test + void throw_a_namespace_exception_when_deleting_a_flow_in_a_missing_namespace() { + when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deleteFlow(NAMESPACE, FLOW_ID)); + } + + @Test + void delete_the_header_and_all_versions_when_the_flow_exists() throws Exception { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1)); + + store.deleteFlow(NAMESPACE, FLOW_ID); + + verify(versionCollection).deleteMany(any(Bson.class)); + verify(headerCollection).deleteOne(any(Bson.class)); + } + + @Test + void throw_a_flow_exception_when_deleting_a_missing_flow() { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0)); + + assertThrows(FlowNotFoundException.class, () -> store.deleteFlow(NAMESPACE, FLOW_ID)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java index 3d293fcc6..5ca717515 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java @@ -6,6 +6,7 @@ import com.mongodb.client.FindIterable; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; +import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; @@ -294,4 +295,30 @@ void overwrite_the_header_details_even_when_blank() throws Exception { // which guarded them. Preserved rather than harmonised — see the store's javadoc. verify(headerCollection, Mockito.times(2)).updateOne(any(Bson.class), any(Bson.class)); } + + // --- deleteInterface --- + + @Test + void throw_a_namespace_exception_when_deleting_an_interface_in_a_missing_namespace() { + when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deleteInterface(NAMESPACE, INTERFACE_ID)); + } + + @Test + void delete_the_header_and_all_versions_when_the_interface_exists() throws Exception { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1)); + + store.deleteInterface(NAMESPACE, INTERFACE_ID); + + verify(versionCollection).deleteMany(any(Bson.class)); + verify(headerCollection).deleteOne(any(Bson.class)); + } + + @Test + void throw_an_interface_exception_when_deleting_a_missing_interface() { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0)); + + assertThrows(InterfaceNotFoundException.class, () -> store.deleteInterface(NAMESPACE, INTERFACE_ID)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoPatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoPatternStoreShould.java index 5c6ef6d0b..72bd0c527 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoPatternStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoPatternStoreShould.java @@ -7,6 +7,7 @@ import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; import com.mongodb.client.model.UpdateOptions; +import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; @@ -178,6 +179,32 @@ void fall_back_to_a_generated_name_for_headers_missing_one() throws NamespaceNot new NamespaceResourceSummary("Pattern 7", "", 7, 0))); } + // --- deletePattern --- + + @Test + void throw_a_namespace_exception_when_deleting_a_pattern_in_a_missing_namespace() { + when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deletePattern(NAMESPACE, PATTERN_ID)); + } + + @Test + void delete_the_header_and_all_versions_when_the_pattern_exists() throws Exception { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1)); + + store.deletePattern(NAMESPACE, PATTERN_ID); + + verify(versionCollection).deleteMany(any(Bson.class)); + verify(headerCollection).deleteOne(any(Bson.class)); + } + + @Test + void throw_a_pattern_exception_when_deleting_a_missing_pattern() { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0)); + + assertThrows(PatternNotFoundException.class, () -> store.deletePattern(NAMESPACE, PATTERN_ID)); + } + @Test void page_the_summary_window_at_the_database() throws NamespaceNotFoundException { FindIterable iterable = stubFind(headerCollection, List.of()); diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoResourceMappingStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoResourceMappingStoreShould.java index 737090354..6967e2c26 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoResourceMappingStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoResourceMappingStoreShould.java @@ -365,6 +365,44 @@ void throw_namespace_not_found_on_delete_when_namespace_does_not_exist() { () -> store.deleteMapping("invalid", ResourceType.PATTERN, "test")); } + // --- deleteMappingByNumericId --- + + @Test + void delete_mapping_by_numeric_id_successfully() throws NamespaceNotFoundException { + when(namespaceStore.namespaceExists("finos")).thenReturn(true); + + var deleteResult = Mockito.mock(com.mongodb.client.result.DeleteResult.class); + when(deleteResult.getDeletedCount()).thenReturn(1L); + when(mappingCollection.deleteOne(any(org.bson.conversions.Bson.class))).thenReturn(deleteResult); + + store.deleteMappingByNumericId("finos", ResourceType.PATTERN, 42); + + verify(mappingCollection).deleteOne(any(org.bson.conversions.Bson.class)); + } + + @Test + void not_throw_when_deleting_a_mapping_by_numeric_id_that_does_not_exist() throws NamespaceNotFoundException { + // Most resources have no custom-id mapping at all — this must be a silent no-op, not + // an error, so a resource delete can call it unconditionally. + when(namespaceStore.namespaceExists("finos")).thenReturn(true); + + var deleteResult = Mockito.mock(com.mongodb.client.result.DeleteResult.class); + when(deleteResult.getDeletedCount()).thenReturn(0L); + when(mappingCollection.deleteOne(any(org.bson.conversions.Bson.class))).thenReturn(deleteResult); + + store.deleteMappingByNumericId("finos", ResourceType.PATTERN, 999); + + verify(mappingCollection).deleteOne(any(org.bson.conversions.Bson.class)); + } + + @Test + void throw_namespace_not_found_when_deleting_a_mapping_by_numeric_id_in_a_missing_namespace() { + when(namespaceStore.namespaceExists("invalid")).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, + () -> store.deleteMappingByNumericId("invalid", ResourceType.PATTERN, 42)); + } + // --- Helper interfaces for Mockito generics --- private interface DocumentMongoCollection extends MongoCollection { diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java index dfc7703cb..c8a64ddd4 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java @@ -6,6 +6,7 @@ import com.mongodb.client.FindIterable; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; +import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; @@ -294,4 +295,30 @@ void overwrite_the_header_details_even_when_blank() throws Exception { // which guarded them. Preserved rather than harmonised — see the store's javadoc. verify(headerCollection, Mockito.times(2)).updateOne(any(Bson.class), any(Bson.class)); } + + // --- deleteStandard --- + + @Test + void throw_a_namespace_exception_when_deleting_a_standard_in_a_missing_namespace() { + when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deleteStandard(NAMESPACE, STANDARD_ID)); + } + + @Test + void delete_the_header_and_all_versions_when_the_standard_exists() throws Exception { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1)); + + store.deleteStandard(NAMESPACE, STANDARD_ID); + + verify(versionCollection).deleteMany(any(Bson.class)); + verify(headerCollection).deleteOne(any(Bson.class)); + } + + @Test + void throw_a_standard_exception_when_deleting_a_missing_standard() { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0)); + + assertThrows(StandardNotFoundException.class, () -> store.deleteStandard(NAMESPACE, STANDARD_ID)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoTimelineStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoTimelineStoreShould.java index 971051c43..6b0f760b9 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoTimelineStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoTimelineStoreShould.java @@ -7,6 +7,7 @@ import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; import com.mongodb.client.model.UpdateOptions; +import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; @@ -412,4 +413,30 @@ void report_a_plain_write_failure_for_other_version_write_errors() { () -> store.updateTimelineForVersion(timeline("1.0.1"))); assertThat(exception.isCapacityExceeded(), is(false)); } + + // --- deleteTimeline --- + + @Test + void throw_a_namespace_exception_when_deleting_a_timeline_in_a_missing_namespace() { + when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deleteTimeline(NAMESPACE, TIMELINE_ID)); + } + + @Test + void delete_the_header_and_all_versions_when_the_timeline_exists() throws Exception { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1)); + + store.deleteTimeline(NAMESPACE, TIMELINE_ID); + + verify(versionCollection).deleteMany(any(Bson.class)); + verify(headerCollection).deleteOne(any(Bson.class)); + } + + @Test + void throw_a_timeline_exception_when_deleting_a_missing_timeline() { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0)); + + assertThrows(TimelineNotFoundException.class, () -> store.deleteTimeline(NAMESPACE, TIMELINE_ID)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteAdrStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteAdrStoreShould.java index e0deb388d..0cf931f63 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteAdrStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteAdrStoreShould.java @@ -382,4 +382,33 @@ public void report_the_revision_already_exists_when_two_writers_race() throws Ex assertThrows(AdrRevisionExistsException.class, () -> store.updateAdrStatus(adrMeta(1, null), Status.accepted)); } + + // --- deleteAdr --- + + @Test + void throw_a_namespace_exception_when_deleting_an_adr_in_a_missing_namespace() { + when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deleteAdr(NAMESPACE, ADR_ID)); + } + + @Test + void delete_the_header_and_all_revisions_when_the_adr_exists() throws Exception { + adrExists(); + stubFind(versionCollection, List.of( + Document.createDocument().put("version", "1"), + Document.createDocument().put("version", "2"))); + + store.deleteAdr(NAMESPACE, ADR_ID); + + verify(versionCollection, org.mockito.Mockito.times(2)).remove(any(Document.class)); + verify(headerCollection).remove(any(Document.class)); + } + + @Test + void throw_an_adr_exception_when_deleting_a_missing_adr() { + adrDoesNotExist(); + + assertThrows(AdrNotFoundException.class, () -> store.deleteAdr(NAMESPACE, ADR_ID)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteArchitectureStoreShould.java index 169d5d23a..454a3f491 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteArchitectureStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteArchitectureStoreShould.java @@ -428,4 +428,33 @@ public void create_the_version_when_updating_one_that_does_not_exist() throws Ex verify(versionCollection).insert(any(Document.class)); } + + // --- deleteArchitecture --- + + @Test + public void throw_a_namespace_exception_when_deleting_an_architecture_in_a_missing_namespace() { + when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deleteArchitecture(NAMESPACE, ARCHITECTURE_ID)); + } + + @Test + public void delete_the_header_and_all_versions_when_the_architecture_exists() throws Exception { + architectureExists(); + stubFind(versionCollection, List.of( + Document.createDocument().put("version", "1.0.0"), + Document.createDocument().put("version", "1.0.1"))); + + store.deleteArchitecture(NAMESPACE, ARCHITECTURE_ID); + + verify(versionCollection, org.mockito.Mockito.times(2)).remove(any(Document.class)); + verify(headerCollection).remove(any(Document.class)); + } + + @Test + public void throw_an_architecture_exception_when_deleting_a_missing_architecture() { + architectureDoesNotExist(); + + assertThrows(ArchitectureNotFoundException.class, () -> store.deleteArchitecture(NAMESPACE, ARCHITECTURE_ID)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteControlStoreShould.java index f870fa53b..638a3576c 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteControlStoreShould.java @@ -12,6 +12,7 @@ import org.finos.calm.domain.exception.ControlConfigurationNotFoundException; import org.finos.calm.domain.exception.ControlConfigurationVersionExistsException; import org.finos.calm.domain.exception.ControlConfigurationVersionNotFoundException; +import org.finos.calm.domain.exception.ControlHasConfigurationsException; import org.finos.calm.domain.exception.ControlNotFoundException; import org.finos.calm.domain.exception.ControlRequirementVersionExistsException; import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; @@ -32,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -113,6 +115,13 @@ private void configurationDoesNotExist() { stubFind(configHeaders, List.of()); } + /** Stubs the count query {@code countHeaders} runs against the configuration namespace. */ + private void configurationCountIs(long count) { + DocumentCursor countCursor = mock(DocumentCursor.class); + when(configHeaders.find(any(Filter.class))).thenReturn(countCursor); + when(countCursor.size()).thenReturn(count); + } + // --- getControlsForDomain --- @Test @@ -502,4 +511,73 @@ void create_configuration_for_version_never_syncs_name_or_description_onto_the_h verify(configHeaders, times(1)).update(any(Filter.class), any(Document.class)); } + + // --- deleteControlRequirement --- + + @Test + void throw_a_domain_exception_when_deleting_a_control_requirement_in_a_missing_domain() { + assertThrows(DomainNotFoundException.class, () -> store.deleteControlRequirement("invalid", CONTROL_ID)); + } + + @Test + void throw_a_control_exception_when_deleting_a_missing_control_requirement() { + controlDoesNotExist(); + + assertThrows(ControlNotFoundException.class, () -> store.deleteControlRequirement(DOMAIN, CONTROL_ID)); + } + + @Test + void refuse_to_delete_a_control_requirement_that_still_has_configurations() { + controlExists(); + configurationCountIs(2); + + ControlHasConfigurationsException exception = assertThrows(ControlHasConfigurationsException.class, + () -> store.deleteControlRequirement(DOMAIN, CONTROL_ID)); + assertThat(exception.getControlId(), is(CONTROL_ID)); + assertThat(exception.getConfigurationCount(), is(2)); + verify(controlHeaders, never()).remove(any(Document.class)); + } + + @Test + void delete_the_requirement_header_and_all_versions_when_it_has_no_configurations() throws Exception { + controlExists(); + configurationCountIs(0); + + store.deleteControlRequirement(DOMAIN, CONTROL_ID); + + verify(controlHeaders).remove(any(Document.class)); + } + + // --- deleteControlConfiguration --- + + @Test + void throw_a_domain_exception_when_deleting_a_configuration_in_a_missing_domain() { + assertThrows(DomainNotFoundException.class, + () -> store.deleteControlConfiguration("invalid", CONTROL_ID, CONFIGURATION_ID)); + } + + @Test + void throw_a_control_exception_when_deleting_a_configuration_for_a_missing_control() { + controlDoesNotExist(); + + assertThrows(ControlNotFoundException.class, + () -> store.deleteControlConfiguration(DOMAIN, CONTROL_ID, CONFIGURATION_ID)); + } + + @Test + void throw_a_configuration_exception_when_deleting_a_missing_configuration() { + configurationDoesNotExist(); + + assertThrows(ControlConfigurationNotFoundException.class, + () -> store.deleteControlConfiguration(DOMAIN, CONTROL_ID, CONFIGURATION_ID)); + } + + @Test + void delete_the_configuration_header_and_all_versions_when_it_exists() throws Exception { + configurationExists(); + + store.deleteControlConfiguration(DOMAIN, CONTROL_ID, CONFIGURATION_ID); + + verify(configHeaders).remove(any(Document.class)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteDecoratorStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteDecoratorStoreShould.java index 33c259e96..89a1a21f8 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteDecoratorStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteDecoratorStoreShould.java @@ -678,4 +678,74 @@ void should_handle_multiple_decorators_in_order() throws NamespaceNotFoundExcept assertEquals(1, decoratorIds.get(2)); verify(namespaceStore).namespaceExists(namespace); } + + @Test + void should_delete_decorator_successfully() throws NamespaceNotFoundException, DecoratorNotFoundException { + // Given + String namespace = "finos"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator1 = Document.createDocument("decoratorId", 1) + .put("decorator", Document.createDocument("unique-id", "decorator-1")); + Document decorator2 = Document.createDocument("decoratorId", 2) + .put("decorator", Document.createDocument("unique-id", "decorator-2")); + Document namespaceDocument = Document.createDocument("namespace", namespace) + .put("decorators", List.of(decorator1, decorator2)); + + when(decoratorCollection.find(any(Filter.class))).thenReturn(cursor); + when(cursor.firstOrNull()).thenReturn(namespaceDocument); + + // When + decoratorStore.deleteDecorator(namespace, 1); + + // Then + verify(namespaceStore).namespaceExists(namespace); + verify(decoratorCollection).update(any(Document.class)); + } + + @Test + void should_throw_decorator_not_found_when_deleting_an_id_not_present() { + // Given + String namespace = "finos"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + Document decorator1 = Document.createDocument("decoratorId", 1) + .put("decorator", Document.createDocument("unique-id", "decorator-1")); + Document namespaceDocument = Document.createDocument("namespace", namespace) + .put("decorators", List.of(decorator1)); + + when(decoratorCollection.find(any(Filter.class))).thenReturn(cursor); + when(cursor.firstOrNull()).thenReturn(namespaceDocument); + + // When & Then + assertThrows(DecoratorNotFoundException.class, () -> decoratorStore.deleteDecorator(namespace, 99)); + + verify(decoratorCollection, never()).update(any(Document.class)); + } + + @Test + void should_throw_decorator_not_found_when_deleting_from_a_namespace_with_no_document() { + // Given + String namespace = "finos"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + when(decoratorCollection.find(any(Filter.class))).thenReturn(cursor); + when(cursor.firstOrNull()).thenReturn(null); + + // When & Then + assertThrows(DecoratorNotFoundException.class, () -> decoratorStore.deleteDecorator(namespace, 1)); + + verify(decoratorCollection, never()).update(any(Document.class)); + } + + @Test + void should_throw_namespace_not_found_when_deleting_decorator_in_unknown_namespace() { + // Given + String namespace = "unknown-namespace"; + when(namespaceStore.namespaceExists(namespace)).thenReturn(false); + + // When & Then + assertThrows(NamespaceNotFoundException.class, () -> decoratorStore.deleteDecorator(namespace, 1)); + + verify(decoratorCollection, never()).find(any(Filter.class)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteFlowStoreShould.java index c056f6313..349db831e 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteFlowStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteFlowStoreShould.java @@ -389,4 +389,33 @@ public void create_the_version_when_updating_one_that_does_not_exist() throws Ex verify(versionCollection).insert(any(Document.class)); } + + // --- deleteFlow --- + + @Test + public void throw_a_namespace_exception_when_deleting_a_flow_in_a_missing_namespace() { + when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deleteFlow(NAMESPACE, FLOW_ID)); + } + + @Test + public void delete_the_header_and_all_versions_when_the_flow_exists() throws Exception { + flowExists(); + stubFind(versionCollection, List.of( + Document.createDocument().put("version", "1.0.0"), + Document.createDocument().put("version", "1.0.1"))); + + store.deleteFlow(NAMESPACE, FLOW_ID); + + verify(versionCollection, org.mockito.Mockito.times(2)).remove(any(Document.class)); + verify(headerCollection).remove(any(Document.class)); + } + + @Test + public void throw_a_flow_exception_when_deleting_a_missing_flow() { + flowDoesNotExist(); + + assertThrows(FlowNotFoundException.class, () -> store.deleteFlow(NAMESPACE, FLOW_ID)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java index ed5e48c7a..4c26068df 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java @@ -263,4 +263,33 @@ public void overwrite_the_header_details_even_when_blank() throws Exception { // Unconditional, unlike Pattern and Flow — Interface's old shape did not guard these. verify(headerCollection, times(2)).update(any(Filter.class), any(Document.class)); } + + // --- deleteInterface --- + + @Test + public void throw_a_namespace_exception_when_deleting_an_interface_in_a_missing_namespace() { + when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deleteInterface(NAMESPACE, INTERFACE_ID)); + } + + @Test + public void delete_the_header_and_all_versions_when_the_interface_exists() throws Exception { + interfaceExists(); + stubFind(versionCollection, List.of( + Document.createDocument().put("version", "1.0.0"), + Document.createDocument().put("version", "1.0.1"))); + + store.deleteInterface(NAMESPACE, INTERFACE_ID); + + verify(versionCollection, times(2)).remove(any(Document.class)); + verify(headerCollection).remove(any(Document.class)); + } + + @Test + public void throw_an_interface_exception_when_deleting_a_missing_interface() { + interfaceDoesNotExist(); + + assertThrows(InterfaceNotFoundException.class, () -> store.deleteInterface(NAMESPACE, INTERFACE_ID)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitritePatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitritePatternStoreShould.java index 114445775..e0787b0d4 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitritePatternStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitritePatternStoreShould.java @@ -431,4 +431,33 @@ public void create_the_version_when_updating_one_that_does_not_exist() throws Ex verify(versionCollection).insert(any(Document.class)); } + + // --- deletePattern --- + + @Test + public void throw_a_namespace_exception_when_deleting_a_pattern_in_a_missing_namespace() { + when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deletePattern(NAMESPACE, PATTERN_ID)); + } + + @Test + public void delete_the_header_and_all_versions_when_the_pattern_exists() throws Exception { + patternExists(); + stubFind(versionCollection, List.of( + Document.createDocument().put("version", "1.0.0"), + Document.createDocument().put("version", "1.0.1"))); + + store.deletePattern(NAMESPACE, PATTERN_ID); + + verify(versionCollection, org.mockito.Mockito.times(2)).remove(any(Document.class)); + verify(headerCollection).remove(any(Document.class)); + } + + @Test + public void throw_a_pattern_exception_when_deleting_a_missing_pattern() { + patternDoesNotExist(); + + assertThrows(PatternNotFoundException.class, () -> store.deletePattern(NAMESPACE, PATTERN_ID)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteResourceMappingStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteResourceMappingStoreShould.java index 986a291ab..27540c717 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteResourceMappingStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteResourceMappingStoreShould.java @@ -410,4 +410,48 @@ void throw_mapping_not_found_on_delete_when_mapping_does_not_exist() { assertThrows(MappingNotFoundException.class, () -> store.deleteMapping(NAMESPACE, ResourceType.PATTERN, "nonexistent")); } + + // --- deleteMappingByNumericId --- + + @Test + void delete_mapping_by_numeric_id_successfully() throws NamespaceNotFoundException { + when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(true); + + Document existing = Document.createDocument() + .put("namespace", NAMESPACE) + .put("customId", "api-gateway") + .put("resourceType", "PATTERN") + .put("numericId", 42); + + DocumentCursor cursor = mock(DocumentCursor.class); + when(cursor.firstOrNull()).thenReturn(existing); + when(mockCollection.find(any(Filter.class))).thenReturn(cursor); + + store.deleteMappingByNumericId(NAMESPACE, ResourceType.PATTERN, 42); + + verify(mockCollection).remove(existing); + } + + @Test + void not_throw_when_deleting_a_mapping_by_numeric_id_that_does_not_exist() throws NamespaceNotFoundException { + // Most resources have no custom-id mapping at all — this must be a silent no-op, not + // an error, so a resource delete can call it unconditionally. + when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(true); + + DocumentCursor cursor = mock(DocumentCursor.class); + when(cursor.firstOrNull()).thenReturn(null); + when(mockCollection.find(any(Filter.class))).thenReturn(cursor); + + store.deleteMappingByNumericId(NAMESPACE, ResourceType.PATTERN, 999); + + verify(mockCollection, never()).remove(any(Document.class)); + } + + @Test + void throw_namespace_not_found_when_deleting_a_mapping_by_numeric_id_in_a_missing_namespace() { + when(mockNamespaceStore.namespaceExists("invalid")).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, + () -> store.deleteMappingByNumericId("invalid", ResourceType.PATTERN, 42)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java index 6024b8e64..358dea793 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java @@ -263,4 +263,33 @@ public void overwrite_the_header_details_even_when_blank() throws Exception { // Unconditional, unlike Pattern and Flow — Standard's old shape did not guard these. verify(headerCollection, times(2)).update(any(Filter.class), any(Document.class)); } + + // --- deleteStandard --- + + @Test + public void throw_a_namespace_exception_when_deleting_a_standard_in_a_missing_namespace() { + when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deleteStandard(NAMESPACE, STANDARD_ID)); + } + + @Test + public void delete_the_header_and_all_versions_when_the_standard_exists() throws Exception { + standardExists(); + stubFind(versionCollection, List.of( + Document.createDocument().put("version", "1.0.0"), + Document.createDocument().put("version", "1.0.1"))); + + store.deleteStandard(NAMESPACE, STANDARD_ID); + + verify(versionCollection, times(2)).remove(any(Document.class)); + verify(headerCollection).remove(any(Document.class)); + } + + @Test + public void throw_a_standard_exception_when_deleting_a_missing_standard() { + standardDoesNotExist(); + + assertThrows(StandardNotFoundException.class, () -> store.deleteStandard(NAMESPACE, STANDARD_ID)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteTimelineStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteTimelineStoreShould.java index df5af265d..10e5a0166 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteTimelineStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteTimelineStoreShould.java @@ -389,4 +389,33 @@ public void create_the_version_when_updating_one_that_does_not_exist() throws Ex verify(versionCollection).insert(any(Document.class)); } + + // --- deleteTimeline --- + + @Test + public void throw_a_namespace_exception_when_deleting_a_timeline_in_a_missing_namespace() { + when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false); + + assertThrows(NamespaceNotFoundException.class, () -> store.deleteTimeline(NAMESPACE, TIMELINE_ID)); + } + + @Test + public void delete_the_header_and_all_versions_when_the_timeline_exists() throws Exception { + timelineExists(); + stubFind(versionCollection, List.of( + Document.createDocument().put("version", "1.0.0"), + Document.createDocument().put("version", "1.0.1"))); + + store.deleteTimeline(NAMESPACE, TIMELINE_ID); + + verify(versionCollection, org.mockito.Mockito.times(2)).remove(any(Document.class)); + verify(headerCollection).remove(any(Document.class)); + } + + @Test + public void throw_a_timeline_exception_when_deleting_a_missing_timeline() { + timelineDoesNotExist(); + + assertThrows(TimelineNotFoundException.class, () -> store.deleteTimeline(NAMESPACE, TIMELINE_ID)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/util/TestMongoVersionDocumentStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/util/TestMongoVersionDocumentStoreShould.java index efe03ab6b..e2576c426 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/util/TestMongoVersionDocumentStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/util/TestMongoVersionDocumentStoreShould.java @@ -7,6 +7,7 @@ import com.mongodb.client.FindIterable; import com.mongodb.client.MongoCollection; import com.mongodb.client.model.UpdateOptions; +import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; import org.bson.BsonDocument; import org.bson.BsonObjectId; @@ -179,6 +180,39 @@ void swallow_a_failure_to_delete_a_header() { assertDoesNotThrow(() -> store.deleteHeader(NAMESPACE, RESOURCE_ID)); } + // --- deleteResource --- + + @Test + void delete_the_header_and_all_versions_of_a_resource() { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1)); + + boolean deleted = store.deleteResource(NAMESPACE, RESOURCE_ID); + + assertThat(deleted, is(true)); + ArgumentCaptor versionFilterCaptor = ArgumentCaptor.forClass(Bson.class); + verify(versionCollection).deleteMany(versionFilterCaptor.capture()); + assertThat(asJson(versionFilterCaptor.getValue()), containsString(NAMESPACE)); + ArgumentCaptor headerFilterCaptor = ArgumentCaptor.forClass(Bson.class); + verify(headerCollection).deleteOne(headerFilterCaptor.capture()); + assertThat(asJson(headerFilterCaptor.getValue()), containsString(NAMESPACE)); + } + + @Test + void report_false_when_deleting_a_resource_that_does_not_exist() { + when(headerCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0)); + + assertThat(store.deleteResource(NAMESPACE, RESOURCE_ID), is(false)); + } + + @Test + void translate_a_write_failure_when_deleting_a_resource() { + when(headerCollection.deleteOne(any(Bson.class))).thenThrow(new MongoTimeoutException("no server")); + + // Unlike deleteHeader, this backs a real DELETE endpoint — a failure here must be + // reported, not swallowed as best-effort cleanup. + assertThrows(StorageWriteException.class, () -> store.deleteResource(NAMESPACE, RESOURCE_ID)); + } + // --- createVersion --- @Test diff --git a/calm-hub/src/test/java/org/finos/calm/store/util/TestNitriteVersionDocumentStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/util/TestNitriteVersionDocumentStoreShould.java index fdaa2678c..b5ea0c77e 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/util/TestNitriteVersionDocumentStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/util/TestNitriteVersionDocumentStoreShould.java @@ -28,6 +28,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -125,6 +126,30 @@ void swallow_a_failure_to_delete_a_header() { assertDoesNotThrow(() -> store.deleteHeader(NAMESPACE, RESOURCE_ID)); } + // --- deleteResource --- + + @Test + void delete_the_header_and_all_versions_of_a_resource() { + Document header = header(RESOURCE_ID, "name", "description", 2); + stubFind(headerCollection, List.of(header)); + stubFind(versionCollection, List.of(versionDocument("1.0.0"), versionDocument("2.0.0"))); + + boolean deleted = store.deleteResource(NAMESPACE, RESOURCE_ID); + + assertThat(deleted, is(true)); + verify(versionCollection, times(2)).remove(any(Document.class)); + verify(headerCollection).remove(header); + } + + @Test + void report_false_when_deleting_a_resource_that_does_not_exist() { + stubFind(headerCollection, List.of()); + + assertThat(store.deleteResource(NAMESPACE, RESOURCE_ID), is(false)); + verify(headerCollection, never()).remove(any(Document.class)); + verifyNoInteractions(versionCollection); + } + // --- createVersion --- @Test