diff --git a/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.component.html b/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.component.html index 1875d12c41d..35da64264a1 100644 --- a/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.component.html +++ b/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.component.html @@ -139,7 +139,15 @@ Change end date +<<<<<<< HEAD + diff --git a/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.component.spec.ts b/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.component.spec.ts index df43ef6011f..26641a97da1 100644 --- a/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.component.spec.ts +++ b/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.component.spec.ts @@ -408,6 +408,35 @@ describe('ApiSubscriptionEditComponent', () => { }); }); + describe('resume failed subscription', () => { + const failedSubscription = BASIC_SUBSCRIPTION(); + failedSubscription.consumerStatus = 'FAILURE'; + + it('should resume failed subscription', async () => { + await initComponent({ subscription: failedSubscription }); + expectApiKeyListGet(); + + const harness = await loader.getHarness(ApiSubscriptionEditHarness); + expect(await harness.resumeFailureBtnIsVisible()).toEqual(true); + + await harness.openResumeFailureDialog(); + + const resumeDialog = await TestbedHarnessEnvironment.documentRootLoader(fixture).getHarness( + MatDialogHarness.with({ selector: '#confirmResumeFailureSubscriptionDialog' }), + ); + expect(await resumeDialog.getTitleText()).toEqual('Resume your failure subscription'); + + const resumeBtn = await resumeDialog.getHarness(MatButtonHarness.with({ text: 'Resume' })); + expect(await resumeBtn.isDisabled()).toEqual(false); + await resumeBtn.click(); + + expectApiSubscriptionFailureResume(SUBSCRIPTION_ID, BASIC_SUBSCRIPTION()); + expectApiSubscriptionGet(BASIC_SUBSCRIPTION()); + expectApiKeyListGet(); + expectApiGet(); + }); + }); + describe('resume subscription', () => { const pausedSubscription = BASIC_SUBSCRIPTION(); pausedSubscription.status = 'PAUSED'; @@ -1277,6 +1306,15 @@ describe('ApiSubscriptionEditComponent', () => { req.flush(subscription); } + function expectApiSubscriptionFailureResume(subscriptionId: string, subscription: Subscription): void { + const req = httpTestingController.expectOne({ + url: `${CONSTANTS_TESTING.env.v2BaseURL}/apis/${API_ID}/subscriptions/${subscriptionId}/_resumeFailure`, + method: 'POST', + }); + expect(req.request.body).toEqual({}); + req.flush(subscription); + } + function expectApiSubscriptionClose(subscriptionId: string, subscription: Subscription): void { const req = httpTestingController.expectOne({ url: `${CONSTANTS_TESTING.env.v2BaseURL}/apis/${API_ID}/subscriptions/${subscriptionId}/_close`, diff --git a/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.component.ts b/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.component.ts index d95d4730a5d..dbe03d3b3da 100644 --- a/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.component.ts +++ b/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.component.ts @@ -372,6 +372,36 @@ export class ApiSubscriptionEditComponent implements OnInit { ); } + resumeFailureSubscription() { + this.matDialog + .open(GioConfirmDialogComponent, { + data: { + title: `Resume your failure subscription`, + content: 'The application will be able to consume your API.', + confirmButton: 'Resume', + }, + role: 'alertdialog', + id: 'confirmResumeFailureSubscriptionDialog', + }) + .afterClosed() + .pipe( + switchMap((confirm) => { + if (confirm) { + return this.apiSubscriptionService.resumeFailure(this.subscription.id, this.apiId); + } + return EMPTY; + }), + takeUntil(this.unsubscribe$), + ) + .subscribe( + (_) => { + this.snackBarService.success(`Subscription resumed`); + this.ngOnInit(); + }, + (err) => this.snackBarService.error(err.message), + ); + } + changeEndDate() { this.matDialog .open< diff --git a/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.harness.ts b/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.harness.ts index 53dde648451..8be636a2a26 100644 --- a/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.harness.ts +++ b/gravitee-apim-console-webui/src/management/api/subscriptions/edit/api-subscription-edit.harness.ts @@ -120,10 +120,18 @@ export class ApiSubscriptionEditHarness extends ComponentHarness { return this.btnIsVisible('Resume'); } + public async resumeFailureBtnIsVisible(): Promise { + return this.btnIsVisible('Resume from failure'); + } + public async openResumeDialog(): Promise { return this.getBtnByText('Resume').then((btn) => btn.click()); } + public async openResumeFailureDialog(): Promise { + return this.getBtnByText('Resume from failure').then((btn) => btn.click()); + } + public async changeEndDateBtnIsVisible(): Promise { return this.btnIsVisible('Change end date'); } diff --git a/gravitee-apim-console-webui/src/services-ngx/api-subscription-v2.service.ts b/gravitee-apim-console-webui/src/services-ngx/api-subscription-v2.service.ts index c2f970597ac..bced4ee3584 100644 --- a/gravitee-apim-console-webui/src/services-ngx/api-subscription-v2.service.ts +++ b/gravitee-apim-console-webui/src/services-ngx/api-subscription-v2.service.ts @@ -104,6 +104,9 @@ export class ApiSubscriptionV2Service { resume(subscriptionId: string, apiId: string): Observable { return this.http.post(`${this.constants.env.v2BaseURL}/apis/${apiId}/subscriptions/${subscriptionId}/_resume`, {}); } + resumeFailure(subscriptionId: string, apiId: string): Observable { + return this.http.post(`${this.constants.env.v2BaseURL}/apis/${apiId}/subscriptions/${subscriptionId}/_resumeFailure`, {}); + } create(apiId: string, createSubscription: CreateSubscription): Observable { return this.http.post(`${this.constants.env.v2BaseURL}/apis/${apiId}/subscriptions`, createSubscription); diff --git a/gravitee-apim-rest-api/gravitee-apim-rest-api-management-v2/gravitee-apim-rest-api-management-v2-rest/src/main/java/io/gravitee/rest/api/management/v2/rest/resource/api/ApiSubscriptionsResource.java b/gravitee-apim-rest-api/gravitee-apim-rest-api-management-v2/gravitee-apim-rest-api-management-v2-rest/src/main/java/io/gravitee/rest/api/management/v2/rest/resource/api/ApiSubscriptionsResource.java index bec560c4846..eaaf7d95e89 100644 --- a/gravitee-apim-rest-api/gravitee-apim-rest-api-management-v2/gravitee-apim-rest-api-management-v2-rest/src/main/java/io/gravitee/rest/api/management/v2/rest/resource/api/ApiSubscriptionsResource.java +++ b/gravitee-apim-rest-api/gravitee-apim-rest-api-management-v2/gravitee-apim-rest-api-management-v2-rest/src/main/java/io/gravitee/rest/api/management/v2/rest/resource/api/ApiSubscriptionsResource.java @@ -57,6 +57,10 @@ import io.gravitee.rest.api.service.common.GraviteeContext; import io.gravitee.rest.api.service.exceptions.InvalidApplicationApiKeyModeException; import io.gravitee.rest.api.service.v4.PlanSearchService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -221,6 +225,28 @@ public Response exportApiSubscriptions( .build(); } + @POST + @Path("/{subscriptionId}/_resumeFailure") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Resume the failed subscription", + description = "User must have the APPLICATION_SUBSCRIPTION[UPDATE] permission to use this service" + ) + @ApiResponse( + responseCode = "200", + description = "Subscription successfully resumed", + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Subscription.class)) + ) + @ApiResponse(responseCode = "400", description = "Status changes not authorized") + @ApiResponse(responseCode = "404", description = "API subscription does not exist") + @ApiResponse(responseCode = "500", description = "Internal server error") + @Permissions({ @Permission(value = RolePermission.API_SUBSCRIPTION, acls = { RolePermissionAction.UPDATE }) }) + public Response resumeFailedSubscription(@PathParam("subscriptionId") String subscriptionId) { + final ExecutionContext executionContext = GraviteeContext.getExecutionContext(); + SubscriptionEntity updatedSubscriptionEntity = subscriptionService.resumeFailed(executionContext, subscriptionId); + return Response.ok(subscriptionMapper.map(updatedSubscriptionEntity)).build(); + } + @POST @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) diff --git a/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/SubscriptionService.java b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/SubscriptionService.java index f6e0d439a94..796446bc7a8 100644 --- a/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/SubscriptionService.java +++ b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/SubscriptionService.java @@ -81,6 +81,7 @@ SubscriptionEntity update( SubscriptionEntity pause(ExecutionContext executionContext, String subscription); SubscriptionEntity resumeConsumer(ExecutionContext executionContext, String subscriptionId); + SubscriptionEntity resumeFailed(ExecutionContext executionContext, String subscriptionId); SubscriptionEntity resume(ExecutionContext executionContext, String subscription); diff --git a/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/exceptions/SubscriptionFailureCustomerStatusRequiredException.java b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/exceptions/SubscriptionFailureCustomerStatusRequiredException.java new file mode 100644 index 00000000000..e79cf7d661b --- /dev/null +++ b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/exceptions/SubscriptionFailureCustomerStatusRequiredException.java @@ -0,0 +1,23 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.rest.api.service.exceptions; + +public class SubscriptionFailureCustomerStatusRequiredException extends RuntimeException { + + public SubscriptionFailureCustomerStatusRequiredException() { + super("Subscription FAILURE customer status required to resume subscription"); + } +} diff --git a/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/impl/SubscriptionServiceImpl.java b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/impl/SubscriptionServiceImpl.java index a0cca71e8e1..82f34a315a3 100644 --- a/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/impl/SubscriptionServiceImpl.java +++ b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/impl/SubscriptionServiceImpl.java @@ -110,6 +110,7 @@ import io.gravitee.rest.api.service.exceptions.PlanOAuth2OrJWTAlreadySubscribedException; import io.gravitee.rest.api.service.exceptions.PlanRestrictedException; import io.gravitee.rest.api.service.exceptions.SubscriptionConsumerStatusNotUpdatableException; +import io.gravitee.rest.api.service.exceptions.SubscriptionFailureCustomerStatusRequiredException; import io.gravitee.rest.api.service.exceptions.SubscriptionFailureException; import io.gravitee.rest.api.service.exceptions.SubscriptionMismatchEnvironmentException; import io.gravitee.rest.api.service.exceptions.SubscriptionNotClosedException; @@ -1033,31 +1034,38 @@ public SubscriptionEntity resumeConsumer(final ExecutionContext executionContext validateConsumerStatus(subscription, genericApiModel); if (subscription.canBeStartedByConsumer()) { - Subscription previousSubscription = new Subscription(subscription); - final Date now = new Date(); - subscription.setUpdatedAt(now); - subscription.setConsumerPausedAt(null); - subscription.setConsumerStatus(Subscription.ConsumerStatus.STARTED); + return resumeSubscription(executionContext, subscription, apiId); + } - subscription = subscriptionRepository.update(subscription); + throw new SubscriptionNotPausedException(subscription); + } catch (TechnicalException ex) { + throw new TechnicalManagementException( + String.format("An error occurs while trying to resume subscription %s", subscriptionId), + ex + ); + } + } - createAudit( - executionContext, - apiId, - subscription.getApplication(), - SUBSCRIPTION_RESUMED_BY_CONSUMER, - subscription.getUpdatedAt(), - previousSubscription, - subscription - ); + @Override + public SubscriptionEntity resumeFailed(final ExecutionContext executionContext, String subscriptionId) { + try { + logger.debug("Resume failed subscription by {} by consumer", subscriptionId); - // active API Keys are automatically unpause - resumeApiKeys(executionContext, subscription); + Subscription subscription = subscriptionRepository + .findById(subscriptionId) + .orElseThrow(() -> new SubscriptionNotFoundException(subscriptionId)); - return convert(subscription); + Subscription.ConsumerStatus consumerStatus = subscription.getConsumerStatus(); + + if (!Subscription.ConsumerStatus.FAILURE.equals(consumerStatus)) { + throw new SubscriptionFailureCustomerStatusRequiredException(); } - throw new SubscriptionNotPausedException(subscription); + String apiId = subscription.getApi(); + final GenericApiModel genericApiModel = apiTemplateService.findByIdForTemplates(executionContext, apiId); + checkApiDefinitionVersion(subscription, genericApiModel); + + return resumeSubscription(executionContext, subscription, apiId); } catch (TechnicalException ex) { throw new TechnicalManagementException( String.format("An error occurs while trying to resume subscription %s", subscriptionId), @@ -1066,6 +1074,32 @@ public SubscriptionEntity resumeConsumer(final ExecutionContext executionContext } } + private SubscriptionEntity resumeSubscription(ExecutionContext executionContext, Subscription subscription, String apiId) + throws TechnicalException { + Subscription previousSubscription = new Subscription(subscription); + final Date now = new Date(); + subscription.setUpdatedAt(now); + subscription.setConsumerPausedAt(null); + subscription.setConsumerStatus(Subscription.ConsumerStatus.STARTED); + + subscription = subscriptionRepository.update(subscription); + + createAudit( + executionContext, + apiId, + subscription.getApplication(), + SUBSCRIPTION_RESUMED_BY_CONSUMER, + subscription.getUpdatedAt(), + previousSubscription, + subscription + ); + + // active API Keys are automatically unpause + resumeApiKeys(executionContext, subscription); + + return convert(subscription); + } + private void resumeApiKeys(ExecutionContext executionContext, Subscription subscription) { streamActiveApiKeys(executionContext, subscription.getId()) .forEach(apiKey -> { @@ -1078,6 +1112,10 @@ private static void validateConsumerStatus(Subscription subscription, GenericApi if (subscription.getConsumerStatus() == Subscription.ConsumerStatus.FAILURE) { throw new SubscriptionFailureException(subscription); } + checkApiDefinitionVersion(subscription, genericApiModel); + } + + private static void checkApiDefinitionVersion(Subscription subscription, GenericApiModel genericApiModel) { if (!DefinitionVersion.V4.equals(genericApiModel.getDefinitionVersion())) { throw new SubscriptionConsumerStatusNotUpdatableException( subscription, diff --git a/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/test/java/io/gravitee/rest/api/service/impl/SubscriptionServiceTest.java b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/test/java/io/gravitee/rest/api/service/impl/SubscriptionServiceTest.java index f05ce7db66f..2716cbf950a 100644 --- a/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/test/java/io/gravitee/rest/api/service/impl/SubscriptionServiceTest.java +++ b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/test/java/io/gravitee/rest/api/service/impl/SubscriptionServiceTest.java @@ -109,6 +109,7 @@ import io.gravitee.rest.api.service.exceptions.PlanNotYetPublishedException; import io.gravitee.rest.api.service.exceptions.PlanRestrictedException; import io.gravitee.rest.api.service.exceptions.SubscriptionConsumerStatusNotUpdatableException; +import io.gravitee.rest.api.service.exceptions.SubscriptionFailureCustomerStatusRequiredException; import io.gravitee.rest.api.service.exceptions.SubscriptionFailureException; import io.gravitee.rest.api.service.exceptions.SubscriptionNotFoundException; import io.gravitee.rest.api.service.exceptions.SubscriptionNotPausableException; @@ -2207,6 +2208,64 @@ public void shouldResumeByConsumer() throws Exception { ); } + @Test + public void shouldResumeFailureByConsumer() throws Exception { + Subscription subscription = buildTestSubscription(ACCEPTED); + subscription.setConsumerStatus(Subscription.ConsumerStatus.FAILURE); + subscription.setApi(API_ID); + + when(subscriptionRepository.findById(SUBSCRIPTION_ID)).thenReturn(Optional.of(subscription)); + io.gravitee.rest.api.model.v4.api.ApiModel apiModel = mock(io.gravitee.rest.api.model.v4.api.ApiModel.class); + when(apiTemplateService.findByIdForTemplates(GraviteeContext.getExecutionContext(), API_ID)).thenReturn(apiModel); + when(apiModel.getDefinitionVersion()).thenReturn(DefinitionVersion.V4); + when(apiModel.getListeners()).thenReturn(List.of(new SubscriptionListener())); + when(subscriptionRepository.update(subscription)).thenReturn(subscription); + final ApiKeyEntity apiKey = buildTestApiKey(subscription.getId(), false, false); + when(apiKeyService.findBySubscription(any(), any())).thenReturn(List.of(apiKey)); + + subscriptionService.resumeFailed(GraviteeContext.getExecutionContext(), SUBSCRIPTION_ID); + + assertThat(subscription.getConsumerPausedAt()).isNull(); + assertThat(subscription.getConsumerStatus()).isEqualTo(Subscription.ConsumerStatus.STARTED); + verify(apiKeyService).update(GraviteeContext.getExecutionContext(), apiKey); + verify(auditService) + .createApiAuditLog( + eq(GraviteeContext.getExecutionContext()), + eq(API_ID), + anyMap(), + eq(Subscription.AuditEvent.SUBSCRIPTION_RESUMED_BY_CONSUMER), + any(), + any(), + any() + ); + verify(auditService) + .createApplicationAuditLog( + eq(GraviteeContext.getExecutionContext()), + eq(APPLICATION_ID), + anyMap(), + eq(Subscription.AuditEvent.SUBSCRIPTION_RESUMED_BY_CONSUMER), + any(), + any(), + any() + ); + } + + @Test(expected = SubscriptionFailureCustomerStatusRequiredException.class) + public void shouldNotResumeFailureByConsumerBecauseApiDefinitionNotV4() throws Exception { + Subscription subscription = buildTestSubscription(ACCEPTED); + when(subscriptionRepository.findById(SUBSCRIPTION_ID)).thenReturn(Optional.of(subscription)); + + subscriptionService.resumeFailed(GraviteeContext.getExecutionContext(), SUBSCRIPTION_ID); + } + + @Test(expected = SubscriptionNotFoundException.class) + public void shouldNotResumeFailureByConsumerBecauseDoesNoExist() throws Exception { + // Stub + when(subscriptionRepository.findById(SUBSCRIPTION_ID)).thenReturn(Optional.empty()); + + subscriptionService.resumeFailed(GraviteeContext.getExecutionContext(), SUBSCRIPTION_ID); + } + @Test public void update_should_not_override_clientId_if_not_present() throws Exception { testUpdateSubscriptionDependingOnClientIdSituation(null, "client-id", null);