diff --git a/gravitee-apim-console-webui/src/management/api/plans/list/api-plan-list.component.html b/gravitee-apim-console-webui/src/management/api/plans/list/api-plan-list.component.html index 74024392856..ae9089045bd 100644 --- a/gravitee-apim-console-webui/src/management/api/plans/list/api-plan-list.component.html +++ b/gravitee-apim-console-webui/src/management/api/plans/list/api-plan-list.component.html @@ -49,8 +49,8 @@ diff --git a/gravitee-apim-console-webui/src/management/api/plans/list/api-plan-list.component.spec.ts b/gravitee-apim-console-webui/src/management/api/plans/list/api-plan-list.component.spec.ts index 6362e8da1e2..6d1e07d87d3 100644 --- a/gravitee-apim-console-webui/src/management/api/plans/list/api-plan-list.component.spec.ts +++ b/gravitee-apim-console-webui/src/management/api/plans/list/api-plan-list.component.spec.ts @@ -459,6 +459,17 @@ describe('ApiPlanListComponent', () => { ], }); + const nativeApi = fakeApiV4({ + id: API_ID, + type: 'NATIVE', + listeners: [ + { + type: 'KAFKA', + host: 'kafka-host', + }, + ], + }); + describe('plansTable tests', () => { it('should display an empty table', fakeAsync(async () => { await initComponent([], asyncApi); @@ -501,6 +512,7 @@ describe('ApiPlanListComponent', () => { ${asyncApi} | ${['Push plan']} ${anotherAsyncApi} | ${['OAuth2', 'JWT', 'API Key', 'Keyless (public)', 'Push plan']} ${httpProxyApi} | ${['OAuth2', 'JWT', 'API Key', 'Keyless (public)']} + ${nativeApi} | ${['OAuth2', 'JWT', 'API Key', 'Keyless (public)']} `( 'should filter plans according to listener types', fakeAsync(async ({ api, expectedPlans }) => { @@ -514,6 +526,168 @@ describe('ApiPlanListComponent', () => { }), ); }); + + describe('Publish plan for Native Kafka API', () => { + describe('Keyless plan in staging', () => { + const KEYLESS_PLAN = fakePlanV4({ security: { type: 'KEY_LESS' }, status: 'STAGING' }); + beforeEach(async () => { + await initComponent([KEYLESS_PLAN], nativeApi, 'STAGING'); + }); + + it('should publish plan and close published plans with authentication', async () => { + const { rowCells } = await computePlansTableCells(); + expect(rowCells).toHaveLength(1); + + const publishBtn = await loader.getHarness(MatButtonHarness.with({ selector: '[aria-label="Publish the plan"]' })); + await publishBtn.click(); + + const publishedApiKeyPlan = fakePlanV4({ id: 'api-key-plan', status: 'PUBLISHED', security: { type: 'API_KEY' } }); + const publishedOAuth2Plan = fakePlanV4({ id: 'oauth2-plan', status: 'PUBLISHED', security: { type: 'OAUTH2' } }); + + expectApiPlansListRequest([publishedApiKeyPlan, publishedOAuth2Plan], ['PUBLISHED']); + + const dialog = await rootLoader.getHarness(MatDialogHarness); + expect(await dialog.getText()).toContain('Your published plans with authentication will be closed automatically.'); + + const publishBtnInDialog = await dialog.getHarness(MatButtonHarness.with({ text: 'Publish' })); + await publishBtnInDialog.click(); + + expectApiPlanCloseRequest(publishedApiKeyPlan); + expectApiPlanCloseRequest(publishedOAuth2Plan); + expectApiPlanPublishRequest(KEYLESS_PLAN); + + // After calling ngOnInit + expectApiGetRequest(nativeApi); + expectApiPlansListRequest([], [...PLAN_STATUS]); + }); + + it('should send request to publish a Keyless plan even if Keyless plan already published', async () => { + const publishBtn = await loader.getHarness(MatButtonHarness.with({ selector: '[aria-label="Publish the plan"]' })); + await publishBtn.click(); + + expectApiPlansListRequest([fakePlanV4({ security: { type: 'KEY_LESS' } })], ['PUBLISHED']); + + const dialog = await rootLoader.getHarness(MatDialogHarness.with({ selector: '#publishPlanDialog' })); + const publishBtnInDialog = await dialog.getHarness(MatButtonHarness.with({ text: 'Publish' })); + await publishBtnInDialog.click(); + + expectApiPlanPublishRequest(KEYLESS_PLAN); + + // After calling ngOnInit + expectApiGetRequest(nativeApi); + expectApiPlansListRequest([], [...PLAN_STATUS]); + }); + + it('should publish Keyless plan if no other plans are published', async () => { + const publishBtn = await loader.getHarness(MatButtonHarness.with({ selector: '[aria-label="Publish the plan"]' })); + await publishBtn.click(); + + expectApiPlansListRequest([], ['PUBLISHED']); + + const dialog = await rootLoader.getHarness(MatDialogHarness.with({ selector: '#publishPlanDialog' })); + const publishBtnInDialog = await dialog.getHarness(MatButtonHarness.with({ text: 'Publish' })); + await publishBtnInDialog.click(); + + expectApiPlanPublishRequest(KEYLESS_PLAN); + + // After calling ngOnInit + expectApiGetRequest(nativeApi); + expectApiPlansListRequest([], [...PLAN_STATUS]); + }); + + it('should cancel publishing a keyless plan', async () => { + const publishBtn = await loader.getHarness(MatButtonHarness.with({ selector: '[aria-label="Publish the plan"]' })); + await publishBtn.click(); + + const publishedApiKeyPlan = fakePlanV4({ id: 'api-key-plan', status: 'PUBLISHED', security: { type: 'API_KEY' } }); + const publishedOAuth2Plan = fakePlanV4({ id: 'oauth2-plan', status: 'PUBLISHED', security: { type: 'OAUTH2' } }); + + expectApiPlansListRequest([publishedApiKeyPlan, publishedOAuth2Plan], ['PUBLISHED']); + + const dialog = await rootLoader.getHarness(MatDialogHarness); + const cancelBtnInDialog = await dialog.getHarness(MatButtonHarness.with({ text: 'Cancel' })); + await cancelBtnInDialog.click(); + }); + }); + + describe('API Key plan in staging', () => { + const API_KEY_PLAN = fakePlanV4({ security: { type: 'API_KEY' }, status: 'STAGING' }); + beforeEach(async () => { + await initComponent([API_KEY_PLAN], nativeApi, 'STAGING'); + }); + + it('should publish API Key plan and close published Keyless plan', async () => { + const publishBtn = await loader.getHarness(MatButtonHarness.with({ selector: '[aria-label="Publish the plan"]' })); + await publishBtn.click(); + + const publishedKeylessPlan = fakePlanV4({ id: 'keyless-plan', status: 'PUBLISHED', security: { type: 'KEY_LESS' } }); + + expectApiPlansListRequest([publishedKeylessPlan], ['PUBLISHED']); + + const dialog = await rootLoader.getHarness(MatDialogHarness); + expect(await dialog.getText()).toContain('Your published Keyless plan will be closed automatically.'); + + const publishBtnInDialog = await dialog.getHarness(MatButtonHarness.with({ text: 'Publish' })); + await publishBtnInDialog.click(); + + expectApiPlanCloseRequest(publishedKeylessPlan); + expectApiPlanPublishRequest(API_KEY_PLAN); + + // After calling ngOnInit + expectApiGetRequest(nativeApi); + expectApiPlansListRequest([], [...PLAN_STATUS]); + }); + + it('should publish an API Key plan if API Key plan already published', async () => { + const publishBtn = await loader.getHarness(MatButtonHarness.with({ selector: '[aria-label="Publish the plan"]' })); + await publishBtn.click(); + + const publishedApiKeyPlan = fakePlanV4({ id: 'api-key-plan', status: 'PUBLISHED', security: { type: 'API_KEY' } }); + + expectApiPlansListRequest([publishedApiKeyPlan], ['PUBLISHED']); + + const dialog = await rootLoader.getHarness(MatDialogHarness.with({ selector: '#publishPlanDialog' })); + const publishBtnInDialog = await dialog.getHarness(MatButtonHarness.with({ text: 'Publish' })); + await publishBtnInDialog.click(); + + expectApiPlanPublishRequest(API_KEY_PLAN); + + // After calling ngOnInit + expectApiGetRequest(nativeApi); + expectApiPlansListRequest([], [...PLAN_STATUS]); + }); + + it('should publish API Key plan if no plans already published', async () => { + const publishBtn = await loader.getHarness(MatButtonHarness.with({ selector: '[aria-label="Publish the plan"]' })); + await publishBtn.click(); + + expectApiPlansListRequest([], ['PUBLISHED']); + + const dialog = await rootLoader.getHarness(MatDialogHarness.with({ selector: '#publishPlanDialog' })); + const publishBtnInDialog = await dialog.getHarness(MatButtonHarness.with({ text: 'Publish' })); + await publishBtnInDialog.click(); + + expectApiPlanPublishRequest(API_KEY_PLAN); + + // After calling ngOnInit + expectApiGetRequest(nativeApi); + expectApiPlansListRequest([], [...PLAN_STATUS]); + }); + + it('should cancel publishing a plan with authentication', async () => { + const publishBtn = await loader.getHarness(MatButtonHarness.with({ selector: '[aria-label="Publish the plan"]' })); + await publishBtn.click(); + + const publishedKeylessPlan = fakePlanV4({ id: 'keyless-plan', status: 'PUBLISHED', security: { type: 'KEY_LESS' } }); + + expectApiPlansListRequest([publishedKeylessPlan], ['PUBLISHED']); + + const dialog = await rootLoader.getHarness(MatDialogHarness); + const cancelBtnInDialog = await dialog.getHarness(MatButtonHarness.with({ text: 'Cancel' })); + await cancelBtnInDialog.click(); + }); + }); + }); }); describe('With a Federated API', () => { @@ -565,8 +739,10 @@ describe('ApiPlanListComponent', () => { }); }); - async function initComponent(plans: Plan[], api: Api = anAPi) { - await TestBed.overrideProvider(ActivatedRoute, { useValue: { snapshot: { params: { apiId: api.id } } } }).compileComponents(); + async function initComponent(plans: Plan[], api: Api = anAPi, activePlanStatusTab: string = 'PUBLISHED') { + await TestBed.overrideProvider(ActivatedRoute, { + useValue: { snapshot: { params: { apiId: api.id }, queryParams: { status: activePlanStatusTab } } }, + }).compileComponents(); fixture = TestBed.createComponent(ApiPlanListComponent); component = fixture.componentInstance; httpTestingController = TestBed.inject(HttpTestingController); diff --git a/gravitee-apim-console-webui/src/management/api/plans/list/api-plan-list.component.ts b/gravitee-apim-console-webui/src/management/api/plans/list/api-plan-list.component.ts index 277a6e32162..ed20200e569 100644 --- a/gravitee-apim-console-webui/src/management/api/plans/list/api-plan-list.component.ts +++ b/gravitee-apim-console-webui/src/management/api/plans/list/api-plan-list.component.ts @@ -15,7 +15,7 @@ */ import { Component, OnDestroy, OnInit } from '@angular/core'; import { catchError, filter, map, switchMap, takeUntil, tap } from 'rxjs/operators'; -import { EMPTY, Observable, of, Subject } from 'rxjs'; +import { EMPTY, forkJoin, Observable, of, Subject } from 'rxjs'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { orderBy } from 'lodash'; import { @@ -142,21 +142,13 @@ export class ApiPlanListComponent implements OnInit, OnDestroy { } public publishPlan(plan: Plan): void { - this.matDialog - .open(GioConfirmDialogComponent, { - width: '500px', - data: { - title: `Publish plan`, - content: `Are you sure you want to publish the plan ${plan.name}?`, - confirmButton: `Publish`, - }, - role: 'alertdialog', - id: 'publishPlanDialog', - }) - .afterClosed() + const publishPlan$ = + this.api.definitionVersion === 'V4' && this.api.type === 'NATIVE' && this.api.listeners.some((l) => l.type === 'KAFKA') + ? this.publishNativeKafkaPlan$(plan) + : this.httpPlanDialog$(plan); + + publishPlan$ .pipe( - filter((confirm) => confirm === true), - switchMap(() => this.plansService.publish(this.api.id, plan.id)), catchError(({ error }) => { this.snackBarService.error(error.message); return EMPTY; @@ -246,6 +238,65 @@ export class ApiPlanListComponent implements OnInit, OnDestroy { .subscribe(); } + private httpPlanDialog$(plan: Plan): Observable { + return this.matDialog + .open(GioConfirmDialogComponent, { + width: '500px', + data: { + title: `Publish plan`, + content: `Are you sure you want to publish the plan ${plan.name}?`, + confirmButton: `Publish`, + }, + role: 'alertdialog', + id: 'publishPlanDialog', + }) + .afterClosed() + .pipe( + filter((confirm) => confirm === true), + switchMap(() => this.plansService.publish(this.api.id, plan.id)), + ); + } + + private publishNativeKafkaPlan$(plan: Plan): Observable { + return this.plansService.list(this.api.id, undefined, ['PUBLISHED'], undefined, 1, 9999).pipe( + switchMap((plansResponse) => { + const publishedKeylessPlan = plansResponse.data.filter((plan) => plan.security.type === 'KEY_LESS'); + const publishedAuthPlans = plansResponse.data.filter((plan) => plan.security.type !== 'KEY_LESS'); + + if (plan.security?.type === 'KEY_LESS' && publishedAuthPlans.length) { + return this.nativeKafkaDialog$(plan, publishedAuthPlans); + } else if (plan.security?.type !== 'KEY_LESS' && publishedKeylessPlan.length) { + return this.nativeKafkaDialog$(plan, publishedKeylessPlan); + } else { + return this.httpPlanDialog$(plan); + } + }), + takeUntil(this.unsubscribe$), + ); + } + + private nativeKafkaDialog$(plan: Plan, plansToClose: Plan[]): Observable { + const plansWithAuthentication = `plan${plansToClose.length > 1 ? 's' : ''} with authentication`; + const content = `Kafka APIs cannot have both plans with and without authentication published. Are you sure you want to publish the plan ${plan.name}?

Your published ${plan.security.type === 'KEY_LESS' ? plansWithAuthentication : 'Keyless plan'} will be closed automatically.`; + return this.matDialog + .open(GioConfirmDialogComponent, { + width: '500px', + data: { + title: `Publish plan`, + content, + confirmButton: `Publish`, + }, + role: 'alertdialog', + id: 'publishNativeKafkaPlanDialog', + }) + .afterClosed() + .pipe( + filter((confirm) => confirm === true), + switchMap(() => forkJoin(plansToClose.map((p) => this.plansService.close(this.api.id, p.id)))), + switchMap(() => this.plansService.publish(this.api.id, plan.id)), + ); + } + private initPlansTableDS(selectedStatus: PlanStatus, fullReload = false): void { // For full reload, we need to reset the number of plans for each status const getApiPlans$: Observable = fullReload diff --git a/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/exceptions/NativePlanAuthenticationConflictException.java b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/exceptions/NativePlanAuthenticationConflictException.java new file mode 100644 index 00000000000..b76e5a2f98c --- /dev/null +++ b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/exceptions/NativePlanAuthenticationConflictException.java @@ -0,0 +1,56 @@ +/* + * 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; + +import static java.util.Collections.singletonMap; + +import io.gravitee.common.http.HttpStatusCode; +import java.util.Map; + +/** + * @author David BRASSELY (david.brassely at graviteesource.com) + * @author GraviteeSource Team + */ +public class NativePlanAuthenticationConflictException extends AbstractManagementException { + + private final boolean planToPublishIsKeyless; + + public NativePlanAuthenticationConflictException(boolean planToPublishIsKeyless) { + this.planToPublishIsKeyless = planToPublishIsKeyless; + } + + @Override + public String getMessage() { + return planToPublishIsKeyless + ? "A plan with authentication is already published for the Native API." + : "A Keyless plan for the Native API is already published."; + } + + @Override + public int getHttpStatusCode() { + return HttpStatusCode.BAD_REQUEST_400; + } + + @Override + public String getTechnicalCode() { + return "plan.native.authentication.conflict"; + } + + @Override + public Map getParameters() { + return singletonMap("planToPublishIsKeyless", Boolean.valueOf(planToPublishIsKeyless).toString()); + } +} diff --git a/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/v4/impl/PlanServiceImpl.java b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/v4/impl/PlanServiceImpl.java index 6b94fb9b6b7..ffe5b3098ea 100644 --- a/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/v4/impl/PlanServiceImpl.java +++ b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/v4/impl/PlanServiceImpl.java @@ -59,6 +59,7 @@ import io.gravitee.rest.api.service.exceptions.ApiDeprecatedException; import io.gravitee.rest.api.service.exceptions.ApiNotFoundException; import io.gravitee.rest.api.service.exceptions.KeylessPlanAlreadyPublishedException; +import io.gravitee.rest.api.service.exceptions.NativePlanAuthenticationConflictException; import io.gravitee.rest.api.service.exceptions.PlanAlreadyClosedException; import io.gravitee.rest.api.service.exceptions.PlanAlreadyDeprecatedException; import io.gravitee.rest.api.service.exceptions.PlanAlreadyPublishedException; @@ -92,6 +93,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; import org.slf4j.Logger; @@ -551,6 +553,10 @@ public GenericPlanEntity publish(final ExecutionContext executionContext, String } } + if (plan.getApiType() == ApiType.NATIVE) { + validateNoConflictingAuthenticationForNativePlan(plan, plans); + } + // Update plan status plan.setStatus(Plan.Status.PUBLISHED); // Update plan order @@ -692,6 +698,23 @@ private void reorderedAndSavePlansAfterRemove(final Plan planRemoved) throws Tec }); } + private void validateNoConflictingAuthenticationForNativePlan(final Plan nativePlanToPublish, final Set apiPlans) { + var planToPublishIsKeyless = nativePlanToPublish.getSecurity() == Plan.PlanSecurityType.KEY_LESS; + + Function conflictingPublishedPlanSecurity = planToPublishIsKeyless + ? plan1 -> plan1.getSecurity() != Plan.PlanSecurityType.KEY_LESS + : plan1 -> plan1.getSecurity() == Plan.PlanSecurityType.KEY_LESS; + + long conflictingPublishedPlansCount = apiPlans + .stream() + .filter(plan1 -> plan1.getStatus() == Plan.Status.PUBLISHED && conflictingPublishedPlanSecurity.apply(plan1)) + .count(); + + if (conflictingPublishedPlansCount > 0) { + throw new NativePlanAuthenticationConflictException(planToPublishIsKeyless); + } + } + private PlanEntity mapToEntity(final Plan plan) { List flows = flowService.findByReference(FlowReferenceType.PLAN, plan.getId()); return planMapper.toEntity(plan, flows); diff --git a/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/test/java/io/gravitee/rest/api/service/v4/impl/PlanService_PublishTest.java b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/test/java/io/gravitee/rest/api/service/v4/impl/PlanService_PublishTest.java index 90fdadae85e..18db38bb27d 100644 --- a/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/test/java/io/gravitee/rest/api/service/v4/impl/PlanService_PublishTest.java +++ b/gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/test/java/io/gravitee/rest/api/service/v4/impl/PlanService_PublishTest.java @@ -36,6 +36,7 @@ import io.gravitee.rest.api.service.SubscriptionService; import io.gravitee.rest.api.service.common.GraviteeContext; import io.gravitee.rest.api.service.exceptions.KeylessPlanAlreadyPublishedException; +import io.gravitee.rest.api.service.exceptions.NativePlanAuthenticationConflictException; import io.gravitee.rest.api.service.exceptions.PlanAlreadyClosedException; import io.gravitee.rest.api.service.exceptions.PlanAlreadyPublishedException; import io.gravitee.rest.api.service.exceptions.PlanGeneralConditionStatusException; @@ -47,6 +48,7 @@ import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.Set; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; @@ -214,6 +216,91 @@ public void shouldPublishAndUpdateNativePlan() throws TechnicalException { verify(flowService, never()).findByReference(any(), any()); } + @Test + public void shouldPublishAndUpdateApiKeyPlanWithOtherAuthPlansPublished() throws TechnicalException { + var apiKeyPlanToPublish = Plan + .builder() + .status(Plan.Status.STAGING) + .type(Plan.PlanType.API) + .apiType(ApiType.NATIVE) + .validation(Plan.PlanValidationType.AUTO) + .api(API_ID) + .security(Plan.PlanSecurityType.API_KEY) + .build(); + + var publishedOAuthPlan = Plan + .builder() + .id("oauth-plan") + .api(API_ID) + .status(Plan.Status.PUBLISHED) + .security(Plan.PlanSecurityType.OAUTH2) + .build(); + + when(planRepository.findById(PLAN_ID)).thenReturn(Optional.of(apiKeyPlanToPublish)); + when(planRepository.findByApi(API_ID)).thenReturn(Set.of(apiKeyPlanToPublish, publishedOAuthPlan)); + when(planRepository.update(apiKeyPlanToPublish)).thenAnswer(returnsFirstArg()); + + planService.publish(GraviteeContext.getExecutionContext(), PLAN_ID); + + verify(planRepository, times(1)).update(apiKeyPlanToPublish.toBuilder().status(Plan.Status.PUBLISHED).build()); + verify(flowCrudService, times(1)).getNativePlanFlows(any()); + verify(flowService, never()).findByReference(any(), any()); + } + + @Test(expected = NativePlanAuthenticationConflictException.class) + public void shouldNotPublishKeylessNativePlanIfAuthPlanPublished() throws TechnicalException { + var stagedKeylessPlan = Plan + .builder() + .status(Plan.Status.STAGING) + .type(Plan.PlanType.API) + .apiType(ApiType.NATIVE) + .validation(Plan.PlanValidationType.AUTO) + .api(API_ID) + .security(Plan.PlanSecurityType.KEY_LESS) + .apiType(ApiType.NATIVE) + .build(); + + var publishedApiKeyPlan = Plan + .builder() + .id("published-api-key") + .api(API_ID) + .security(Plan.PlanSecurityType.API_KEY) + .status(Plan.Status.PUBLISHED) + .build(); + + when(planRepository.findById(PLAN_ID)).thenReturn(Optional.of(stagedKeylessPlan)); + when(planRepository.findByApi(API_ID)).thenReturn(Set.of(stagedKeylessPlan, publishedApiKeyPlan)); + + planService.publish(GraviteeContext.getExecutionContext(), PLAN_ID); + } + + @Test(expected = NativePlanAuthenticationConflictException.class) + public void shouldNotPublishAuthNativePlanIfKeylessPlanPublished() throws TechnicalException { + var stagedApiKeyPlan = Plan + .builder() + .status(Plan.Status.STAGING) + .type(Plan.PlanType.API) + .apiType(ApiType.NATIVE) + .validation(Plan.PlanValidationType.AUTO) + .api(API_ID) + .security(Plan.PlanSecurityType.API_KEY) + .apiType(ApiType.NATIVE) + .build(); + + var publishedKeylessPlan = Plan + .builder() + .id("published-keyless") + .api(API_ID) + .security(Plan.PlanSecurityType.KEY_LESS) + .status(Plan.Status.PUBLISHED) + .build(); + + when(planRepository.findById(PLAN_ID)).thenReturn(Optional.of(stagedApiKeyPlan)); + when(planRepository.findByApi(API_ID)).thenReturn(Set.of(stagedApiKeyPlan, publishedKeylessPlan)); + + planService.publish(GraviteeContext.getExecutionContext(), PLAN_ID); + } + @Test public void shouldPublish_WithPublishGCPage() throws TechnicalException { final String GC_PAGE_ID = "GC_PAGE_ID";