Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apim 8042 native api publish auth and non auth plans #10212

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@
<mat-button-toggle-group class="plans__filters" aria-label="Plan status filters" [value]="status">
<mat-button-toggle
*ngFor="let planStatus of apiPlanStatus"
[attr.aria-label]="'Filter on' + planStatus.name + 'plans'"
[matTooltip]="'Filter on' + planStatus.name + 'plans'"
[attr.aria-label]="'Filter on ' + planStatus.name + ' plans'"
[matTooltip]="'Filter on ' + planStatus.name + ' plans'"
[value]="planStatus.name"
(click)="searchPlansByStatus(planStatus.name)"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -142,21 +142,13 @@ export class ApiPlanListComponent implements OnInit, OnDestroy {
}

public publishPlan(plan: Plan): void {
this.matDialog
.open<GioConfirmDialogComponent, GioConfirmDialogData>(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;
Expand Down Expand Up @@ -246,6 +238,65 @@ export class ApiPlanListComponent implements OnInit, OnDestroy {
.subscribe();
}

private httpPlanDialog$(plan: Plan): Observable<Plan> {
return this.matDialog
.open<GioConfirmDialogComponent, GioConfirmDialogData>(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<Plan> {
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<Plan> {
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}? <br /><br />Your published ${plan.security.type === 'KEY_LESS' ? plansWithAuthentication : 'Keyless plan'} will be closed automatically.`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to check the behaviour with subscription. And indicate here that closing a plan with security will close the associated subscriptions. Like the message that closes a plan with security

return this.matDialog
.open<GioConfirmDialogComponent, GioConfirmDialogData>(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<Plan[]> = fullReload
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> getParameters() {
return singletonMap("planToPublishIsKeyless", Boolean.valueOf(planToPublishIsKeyless).toString());
}
}
Loading