Browse Source

SQBILLING-93 SonarCloud notifies Muppet when an organization is deleted

tags/7.5
Teryk Bellahsene 6 years ago
parent
commit
8b16ffe330

+ 5
- 0
server/sonar-server/src/main/java/org/sonar/server/organization/BillingValidations.java View File

@@ -48,6 +48,11 @@ public interface BillingValidations {
*/
boolean canUpdateProjectVisibilityToPrivate(Organization organization);

/**
* Actions to do on an organization deletion
*/
void onDelete(Organization organization);

class Organization {
private final String key;
private final String uuid;

+ 8
- 0
server/sonar-server/src/main/java/org/sonar/server/organization/BillingValidationsProxyImpl.java View File

@@ -55,4 +55,12 @@ public class BillingValidationsProxyImpl implements BillingValidationsProxy {
public boolean canUpdateProjectVisibilityToPrivate(Organization organization) {
return billingValidationsExtension == null || billingValidationsExtension.canUpdateProjectVisibilityToPrivate(organization);
}

@Override
public void onDelete(Organization organization) {
if (billingValidationsExtension == null) {
return;
}
billingValidationsExtension.onDelete(organization);
}
}

+ 8
- 3
server/sonar-server/src/main/java/org/sonar/server/organization/ws/DeleteAction.java View File

@@ -35,6 +35,8 @@ import org.sonar.db.organization.OrganizationDto;
import org.sonar.db.qualitygate.QualityGateDto;
import org.sonar.db.qualityprofile.QProfileDto;
import org.sonar.server.component.ComponentCleanerService;
import org.sonar.server.organization.BillingValidations;
import org.sonar.server.organization.BillingValidationsProxy;
import org.sonar.server.organization.DefaultOrganization;
import org.sonar.server.organization.DefaultOrganizationProvider;
import org.sonar.server.organization.OrganizationFlags;
@@ -62,10 +64,11 @@ public class DeleteAction implements OrganizationsWsAction {
private final UserIndexer userIndexer;
private final QProfileFactory qProfileFactory;
private final ProjectLifeCycleListeners projectLifeCycleListeners;
private final BillingValidationsProxy billingValidations;

public DeleteAction(UserSession userSession, DbClient dbClient, DefaultOrganizationProvider defaultOrganizationProvider,
ComponentCleanerService componentCleanerService, OrganizationFlags organizationFlags, UserIndexer userIndexer,
QProfileFactory qProfileFactory, ProjectLifeCycleListeners projectLifeCycleListeners) {
public DeleteAction(UserSession userSession, DbClient dbClient, DefaultOrganizationProvider defaultOrganizationProvider, ComponentCleanerService componentCleanerService,
OrganizationFlags organizationFlags, UserIndexer userIndexer, QProfileFactory qProfileFactory, ProjectLifeCycleListeners projectLifeCycleListeners,
BillingValidationsProxy billingValidations) {
this.userSession = userSession;
this.dbClient = dbClient;
this.defaultOrganizationProvider = defaultOrganizationProvider;
@@ -74,6 +77,7 @@ public class DeleteAction implements OrganizationsWsAction {
this.userIndexer = userIndexer;
this.qProfileFactory = qProfileFactory;
this.projectLifeCycleListeners = projectLifeCycleListeners;
this.billingValidations = billingValidations;
}

@Override
@@ -116,6 +120,7 @@ public class DeleteAction implements OrganizationsWsAction {
deleteQualityProfiles(dbSession, organization);
deleteQualityGates(dbSession, organization);
deleteOrganization(dbSession, organization);
billingValidations.onDelete(new BillingValidations.Organization(organization.getKey(), organization.getUuid()));

response.noContent();
}

+ 16
- 2
server/sonar-server/src/test/java/org/sonar/server/organization/ws/DeleteActionTest.java View File

@@ -59,6 +59,8 @@ import org.sonar.server.es.SearchOptions;
import org.sonar.server.exceptions.ForbiddenException;
import org.sonar.server.exceptions.NotFoundException;
import org.sonar.server.exceptions.UnauthorizedException;
import org.sonar.server.organization.BillingValidations;
import org.sonar.server.organization.BillingValidationsProxy;
import org.sonar.server.organization.TestDefaultOrganizationProvider;
import org.sonar.server.organization.TestOrganizationFlags;
import org.sonar.server.project.Project;
@@ -119,11 +121,13 @@ public class DeleteActionTest {
private final WebhookDeliveryDao deliveryDao = dbClient.webhookDeliveryDao();
private final WebhookDeliveryDbTester webhookDeliveryDbTester = db.webhookDelivery();
private ProjectLifeCycleListeners projectLifeCycleListeners = mock(ProjectLifeCycleListeners.class);
private BillingValidationsProxy billingValidationsProxy = mock(BillingValidationsProxy.class);
private WsActionTester wsTester = new WsActionTester(
new DeleteAction(userSession, dbClient, defaultOrganizationProvider, spiedComponentCleanerService, organizationFlags, userIndexer, qProfileFactory, projectLifeCycleListeners));
new DeleteAction(userSession, dbClient, defaultOrganizationProvider, spiedComponentCleanerService, organizationFlags, userIndexer, qProfileFactory, projectLifeCycleListeners,
billingValidationsProxy));

@Test
public void test_definition() {
public void definition() {
WebService.Action action = wsTester.getDef();
assertThat(action.key()).isEqualTo("delete");
assertThat(action.isPost()).isTrue();
@@ -542,6 +546,16 @@ public class DeleteActionTest {
}
}

@Test
public void call_billing_validation_on_delete() {
OrganizationDto organization = db.organizations().insert();
logInAsAdministrator(organization);

sendRequest(organization);

verify(billingValidationsProxy).onDelete(any(BillingValidations.Organization.class));
}

@DataProvider
public static Object[][] indexOfFailingProjectDeletion() {
return new Object[][]{

+ 17
- 0
server/sonar-web/src/main/js/api/organizations.ts View File

@@ -104,3 +104,20 @@ export function changeProjectVisibility(
): Promise<void> {
return post('/api/organizations/update_project_visibility', { organization, projectVisibility });
}

export interface OrganizationBilling {
nclocCount: number;
subscription: {
plan?: {
maxNcloc: number;
price: number;
};
nextBillingDate?: string;
status: 'active' | 'inactive' | 'suspended';
trial: boolean;
};
}

export function getOrganizationBilling(organization: string): Promise<OrganizationBilling> {
return getJSON('/api/billing/show', { organization, p: 1, ps: 1 });
}

+ 0
- 122
server/sonar-web/src/main/js/apps/organizations/components/OrganizationDelete.js View File

@@ -1,122 +0,0 @@
/*
* SonarQube
* Copyright (C) 2009-2018 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
// @flow
import React from 'react';
import Helmet from 'react-helmet';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import Modal from '../../../components/controls/Modal';
import { translate } from '../../../helpers/l10n';
import { getOrganizationByKey } from '../../../store/rootReducer';
import { deleteOrganization } from '../actions';

class OrganizationDelete extends React.PureComponent {
/*:: props: {
organization: {
key: string,
name: string
},
router: {
replace: string => void
},
deleteOrganization: string => Promise<*>
};
*/

state = {
deleting: false,
loading: false
};

handleSubmit = (e /*: Object */) => {
e.preventDefault();
this.setState({ loading: true });
this.props.deleteOrganization(this.props.organization.key).then(() => {
this.props.router.replace('/');
});
};

handleOpenModal = () => {
this.setState({ deleting: true });
};

handleCloseModal = () => {
this.setState({ deleting: false });
};

renderModal() {
return (
<Modal contentLabel="modal form" onRequestClose={this.handleCloseModal}>
<header className="modal-head">
<h2>{translate('organization.delete')}</h2>
</header>

<form onSubmit={this.handleSubmit}>
<div className="modal-body">{translate('organization.delete.question')}</div>

<footer className="modal-foot">
{this.state.loading ? (
<i className="spinner" />
) : (
<div>
<button type="submit" className="button-red">
{translate('delete')}
</button>
<button type="reset" className="button-link" onClick={this.handleCloseModal}>
{translate('cancel')}
</button>
</div>
)}
</footer>
</form>
</Modal>
);
}

render() {
const title = translate('organization.delete');
return (
<div className="page page-limited">
<Helmet title={title} />

<header className="page-header">
<h1 className="page-title">{title}</h1>
<div className="page-description">{translate('organization.delete.description')}</div>
</header>

<div>
<button
className="button-red"
disabled={this.state.loading || this.state.deleting}
onClick={this.handleOpenModal}>
{translate('delete')}
</button>
{this.state.deleting && this.renderModal()}
</div>
</div>
);
}
}

const mapDispatchToProps = { deleteOrganization };

export default connect(null, mapDispatchToProps)(withRouter(OrganizationDelete));

export const UnconnectedOrganizationDelete = OrganizationDelete;

+ 135
- 0
server/sonar-web/src/main/js/apps/organizations/components/OrganizationDelete.tsx View File

@@ -0,0 +1,135 @@
/*
* SonarQube
* Copyright (C) 2009-2018 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import * as PropTypes from 'prop-types';
import Helmet from 'react-helmet';
import { connect } from 'react-redux';
import ConfirmButton from '../../../components/controls/ConfirmButton';
import { translate } from '../../../helpers/l10n';
import { deleteOrganization } from '../actions';
import { Organization } from '../../../app/types';
import { Button } from '../../../components/ui/buttons';
import { getOrganizationBilling } from '../../../api/organizations';

interface DispatchToProps {
deleteOrganization: (key: string) => Promise<void>;
}

interface OwnProps {
organization: Pick<Organization, 'key' | 'name'>;
}

type Props = OwnProps & DispatchToProps;

interface State {
hasPaidPlan?: boolean;
}

export class OrganizationDelete extends React.PureComponent<Props, State> {
mounted = false;
static contextTypes = {
router: PropTypes.object,
onSonarCloud: PropTypes.bool
};

state: State = {};

componentDidMount() {
this.mounted = true;
this.fetchOrganizationPlanInfo();
}

componentWillUnmount() {
this.mounted = false;
}

fetchOrganizationPlanInfo = () => {
if (this.context.onSonarCloud) {
getOrganizationBilling(this.props.organization.key).then(
billingInfo => {
if (this.mounted) {
this.setState({
hasPaidPlan: billingInfo.subscription.status !== 'inactive'
});
}
},
() => {
if (this.mounted) {
this.setState({ hasPaidPlan: false });
}
}
);
}
};

onDelete = () => {
return this.props.deleteOrganization(this.props.organization.key).then(() => {
this.context.router.replace('/');
});
};

render() {
const { hasPaidPlan } = this.state;
const { onSonarCloud } = this.context;
const title = translate('organization.delete');
return (
<>
<Helmet title={title} />
<div className="page page-limited">
<header className="page-header">
<h1 className="page-title">{title}</h1>
<div className="page-description">
{onSonarCloud
? translate('organization.delete.description.sonarcloud')
: translate('organization.delete.description')}
</div>
</header>
<ConfirmButton
confirmButtonText={translate('delete')}
isDestructive={true}
modalBody={
<div>
{translate('organization.delete.question')}
{hasPaidPlan && (
<p className="alert alert-warn big-spacer-top">
{translate('organization.delete.sonarcloud.paid_plan_info')}
</p>
)}
</div>
}
modalHeader={translate('organization.delete')}
onConfirm={this.onDelete}>
{({ onClick }) => (
<Button className="js-custom-measure-delete button-red" onClick={onClick}>
{translate('delete')}
</Button>
)}
</ConfirmButton>
</div>
</>
);
}
}

const mapDispatchToProps: DispatchToProps = { deleteOrganization: deleteOrganization as any };

export default connect<null, DispatchToProps, OwnProps>(null, mapDispatchToProps)(
OrganizationDelete
);

+ 0
- 34
server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationDelete-test.js View File

@@ -1,34 +0,0 @@
/*
* SonarQube
* Copyright (C) 2009-2018 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { UnconnectedOrganizationDelete } from '../OrganizationDelete';

it('smoke test', () => {
const organization = { key: 'foo', name: 'Foo' };
const wrapper = shallow(<UnconnectedOrganizationDelete organization={organization} />);
expect(wrapper).toMatchSnapshot();

wrapper.setState({ deleting: true });
expect(wrapper).toMatchSnapshot();

wrapper.setState({ loading: true });
expect(wrapper).toMatchSnapshot();
});

+ 67
- 0
server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationDelete-test.tsx View File

@@ -0,0 +1,67 @@
/*
* SonarQube
* Copyright (C) 2009-2018 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { shallow } from 'enzyme';
import { OrganizationDelete } from '../OrganizationDelete';
import { getOrganizationBilling } from '../../../../api/organizations';
import { waitAndUpdate } from '../../../../helpers/testUtils';

jest.mock('../../../../api/organizations', () => ({
getOrganizationBilling: jest.fn(() =>
Promise.resolve({ nclocCount: 1000, subscription: { status: 'active', trial: true } })
)
}));

beforeEach(() => {
(getOrganizationBilling as jest.Mock<any>).mockClear();
});

it('smoke test', () => {
expect(getWrapper()).toMatchSnapshot();
});

it('should redirect the page', async () => {
const deleteOrganization = jest.fn(() => Promise.resolve());
const replace = jest.fn();
const wrapper = getWrapper({ deleteOrganization }, { router: { replace } });
(wrapper.instance() as OrganizationDelete).onDelete();
await waitAndUpdate(wrapper);
expect(deleteOrganization).toHaveBeenCalledWith('foo');
expect(replace).toHaveBeenCalledWith('/');
});

it('should show a info message for paying organization', async () => {
const wrapper = getWrapper({}, { onSonarCloud: true });
await waitAndUpdate(wrapper);
expect(getOrganizationBilling).toHaveBeenCalledWith('foo');
expect(wrapper).toMatchSnapshot();
});

function getWrapper(props = {}, context = {}) {
return shallow(
<OrganizationDelete
deleteOrganization={jest.fn(() => Promise.resolve())}
organization={{ key: 'foo', name: 'Foo' }}
{...props}
/>,

{ context: { router: { replace: jest.fn() }, ...context } }
);
}

+ 0
- 174
server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationDelete-test.js.snap View File

@@ -1,174 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`smoke test 1`] = `
<div
className="page page-limited"
>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
title="organization.delete"
/>
<header
className="page-header"
>
<h1
className="page-title"
>
organization.delete
</h1>
<div
className="page-description"
>
organization.delete.description
</div>
</header>
<div>
<button
className="button-red"
disabled={false}
onClick={[Function]}
>
delete
</button>
</div>
</div>
`;

exports[`smoke test 2`] = `
<div
className="page page-limited"
>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
title="organization.delete"
/>
<header
className="page-header"
>
<h1
className="page-title"
>
organization.delete
</h1>
<div
className="page-description"
>
organization.delete.description
</div>
</header>
<div>
<button
className="button-red"
disabled={true}
onClick={[Function]}
>
delete
</button>
<Modal
contentLabel="modal form"
onRequestClose={[Function]}
>
<header
className="modal-head"
>
<h2>
organization.delete
</h2>
</header>
<form
onSubmit={[Function]}
>
<div
className="modal-body"
>
organization.delete.question
</div>
<footer
className="modal-foot"
>
<div>
<button
className="button-red"
type="submit"
>
delete
</button>
<button
className="button-link"
onClick={[Function]}
type="reset"
>
cancel
</button>
</div>
</footer>
</form>
</Modal>
</div>
</div>
`;

exports[`smoke test 3`] = `
<div
className="page page-limited"
>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
title="organization.delete"
/>
<header
className="page-header"
>
<h1
className="page-title"
>
organization.delete
</h1>
<div
className="page-description"
>
organization.delete.description
</div>
</header>
<div>
<button
className="button-red"
disabled={true}
onClick={[Function]}
>
delete
</button>
<Modal
contentLabel="modal form"
onRequestClose={[Function]}
>
<header
className="modal-head"
>
<h2>
organization.delete
</h2>
</header>
<form
onSubmit={[Function]}
>
<div
className="modal-body"
>
organization.delete.question
</div>
<footer
className="modal-foot"
>
<i
className="spinner"
/>
</footer>
</form>
</Modal>
</div>
</div>
`;

+ 84
- 0
server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationDelete-test.tsx.snap View File

@@ -0,0 +1,84 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should show a info message for paying organization 1`] = `
<React.Fragment>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
title="organization.delete"
/>
<div
className="page page-limited"
>
<header
className="page-header"
>
<h1
className="page-title"
>
organization.delete
</h1>
<div
className="page-description"
>
organization.delete.description.sonarcloud
</div>
</header>
<ConfirmButton
confirmButtonText="delete"
isDestructive={true}
modalBody={
<div>
organization.delete.question
<p
className="alert alert-warn big-spacer-top"
>
organization.delete.sonarcloud.paid_plan_info
</p>
</div>
}
modalHeader="organization.delete"
onConfirm={[Function]}
/>
</div>
</React.Fragment>
`;

exports[`smoke test 1`] = `
<React.Fragment>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
title="organization.delete"
/>
<div
className="page page-limited"
>
<header
className="page-header"
>
<h1
className="page-title"
>
organization.delete
</h1>
<div
className="page-description"
>
organization.delete.description
</div>
</header>
<ConfirmButton
confirmButtonText="delete"
isDestructive={true}
modalBody={
<div>
organization.delete.question
</div>
}
modalHeader="organization.delete"
onConfirm={[Function]}
/>
</div>
</React.Fragment>
`;

+ 2
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -2517,6 +2517,8 @@ organization.avatar.preview=Preview
organization.created=Organization "{0}" has been created.
organization.delete=Delete Organization
organization.delete.description=Delete this organization from SonarQube. All projects belonging to the organization will be deleted as well. The operation cannot be undone.
organization.delete.description.sonarcloud=Delete this organization from SonarCloud. All projects belonging to the organization will be deleted as well. The operation cannot be undone.
organization.delete.sonarcloud.paid_plan_info=Your current paid plan subscription will stop and you won't be charged anymore.
organization.delete.question=Are you sure you want to delete this organization?
organization.deleted=Organization has been deleted.
organization.description=Description

+ 5
- 0
tests/plugins/fake-billing-plugin/src/main/java/FakeBillingValidations.java View File

@@ -77,4 +77,9 @@ public class FakeBillingValidations implements BillingValidationsExtension {
}
return !settings.getBoolean(PREVENT_UPDATING_PROJECTS_VISIBILITY_TO_PRIVATE_SETTING);
}

@Override
public void onDelete(Organization organization) {
// do nothing
}
}

Loading…
Cancel
Save