Browse Source

fix(codeowners): Prevent interaction from on users without permissions (#28197)

* ✨ Prevent interaction on issue owners/mapping pages

* ✅ Add frontend tests for permissions

* 🥣 fix flaky test

* 💬 Use correct mapping in tooltip
Leander Rodrigues 3 years ago
parent
commit
6240073827

+ 23 - 10
static/app/components/integrationExternalMappings.tsx

@@ -35,16 +35,29 @@ class IntegrationExternalMappings extends Component<Props, State> {
             <HeaderLayout>
               <ExternalNameColumn>{tct('External [type]', {type})}</ExternalNameColumn>
               <SentryNameColumn>{tct('Sentry [type]', {type})}</SentryNameColumn>
-              <ButtonColumn>
-                <AddButton
-                  data-test-id="add-mapping-button"
-                  onClick={() => onCreateOrEdit()}
-                  size="xsmall"
-                  icon={<IconAdd size="xs" isCircled />}
-                >
-                  {tct('Add [type] Mapping', {type})}
-                </AddButton>
-              </ButtonColumn>
+              <Access access={['org:integrations']}>
+                {({hasAccess}) => (
+                  <ButtonColumn>
+                    <Tooltip
+                      title={tct(
+                        'You must be an organization owner, manager or admin to edit or remove a [type] mapping.',
+                        {type}
+                      )}
+                      disabled={hasAccess}
+                    >
+                      <AddButton
+                        data-test-id="add-mapping-button"
+                        onClick={() => onCreateOrEdit()}
+                        size="xsmall"
+                        icon={<IconAdd size="xs" isCircled />}
+                        disabled={!hasAccess}
+                      >
+                        {tct('Add [type] Mapping', {type})}
+                      </AddButton>
+                    </Tooltip>
+                  </ButtonColumn>
+                )}
+              </Access>
             </HeaderLayout>
           </PanelHeader>
           <PanelBody>

+ 25 - 9
static/app/views/organizationIntegrations/integrationCodeMappings.tsx

@@ -5,6 +5,7 @@ import * as qs from 'query-string';
 
 import {addErrorMessage, addSuccessMessage} from 'app/actionCreators/indicator';
 import {openModal} from 'app/actionCreators/modal';
+import Access from 'app/components/acl/access';
 import AsyncComponent from 'app/components/asyncComponent';
 import Button from 'app/components/button';
 import ExternalLink from 'app/components/links/externalLink';
@@ -16,6 +17,7 @@ import RepositoryProjectPathConfigRow, {
   NameRepoColumn,
   OutputPathColumn,
 } from 'app/components/repositoryProjectPathConfigRow';
+import Tooltip from 'app/components/tooltip';
 import {IconAdd} from 'app/icons';
 import {t, tct} from 'app/locale';
 import space from 'app/styles/space';
@@ -194,15 +196,29 @@ class IntegrationCodeMappings extends AsyncComponent<Props, State> {
               <NameRepoColumn>{t('Code Mappings')}</NameRepoColumn>
               <InputPathColumn>{t('Stack Trace Root')}</InputPathColumn>
               <OutputPathColumn>{t('Source Code Root')}</OutputPathColumn>
-              <ButtonColumn>
-                <AddButton
-                  onClick={() => this.openModal()}
-                  size="xsmall"
-                  icon={<IconAdd size="xs" isCircled />}
-                >
-                  {t('Add Mapping')}
-                </AddButton>
-              </ButtonColumn>
+
+              <Access access={['org:integrations']}>
+                {({hasAccess}) => (
+                  <ButtonColumn>
+                    <Tooltip
+                      title={t(
+                        'You must be an organization owner, manager or admin to edit or remove a code mapping.'
+                      )}
+                      disabled={hasAccess}
+                    >
+                      <AddButton
+                        data-test-id="add-mapping-button"
+                        onClick={() => this.openModal()}
+                        size="xsmall"
+                        icon={<IconAdd size="xs" isCircled />}
+                        disabled={!hasAccess}
+                      >
+                        {t('Add Code Mapping')}
+                      </AddButton>
+                    </Tooltip>
+                  </ButtonColumn>
+                )}
+              </Access>
             </HeaderLayout>
           </PanelHeader>
           <PanelBody>

+ 2 - 6
static/app/views/settings/project/projectOwnership/codeowners.tsx

@@ -85,13 +85,9 @@ class CodeOwnersPanel extends Component<Props> {
                 onConfirm={() => this.handleDelete(codeowner)}
                 message={t('Are you sure you want to remove this CODEOWNERS file?')}
                 key="confirm-delete"
+                disabled={disabled}
               >
-                <Button
-                  key="delete"
-                  icon={<IconDelete size="xs" />}
-                  size="xsmall"
-                  disabled={disabled}
-                />
+                <Button key="delete" icon={<IconDelete size="xs" />} size="xsmall" />
               </Confirm>,
             ]}
           />

+ 15 - 8
static/app/views/settings/project/projectOwnership/index.tsx

@@ -3,6 +3,7 @@ import {RouteComponentProps} from 'react-router';
 import styled from '@emotion/styled';
 
 import {openEditOwnershipRules, openModal} from 'app/actionCreators/modal';
+import Access from 'app/components/acl/access';
 import Feature from 'app/components/acl/feature';
 import Alert from 'app/components/alert';
 import Button from 'app/components/button';
@@ -263,14 +264,20 @@ tags.sku_class:enterprise #enterprise`;
                 {t('View Issues')}
               </Button>
               <Feature features={['integrations-codeowners']}>
-                <CodeOwnerButton
-                  onClick={this.handleAddCodeOwner}
-                  size="small"
-                  priority="primary"
-                  data-test-id="add-codeowner-button"
-                >
-                  {t('Add CODEOWNERS File')}
-                </CodeOwnerButton>
+                <Access access={['project:write']}>
+                  {({hasAccess}) =>
+                    hasAccess && (
+                      <CodeOwnerButton
+                        onClick={this.handleAddCodeOwner}
+                        size="small"
+                        priority="primary"
+                        data-test-id="add-codeowner-button"
+                      >
+                        {t('Add CODEOWNERS File')}
+                      </CodeOwnerButton>
+                    )
+                  }
+                </Access>
               </Feature>
             </Fragment>
           }

+ 34 - 2
tests/js/spec/views/organizationIntegrations/integrationCodeMappings.spec.jsx

@@ -3,6 +3,7 @@ import {mountGlobalModal} from 'sentry-test/modal';
 import {selectByValue} from 'sentry-test/select-new';
 
 import {Client} from 'app/api';
+import ModalStore from 'app/stores/modalStore';
 import IntegrationCodeMappings from 'app/views/organizationIntegrations/integrationCodeMappings';
 
 const mockResponse = mocks => {
@@ -26,6 +27,10 @@ describe('IntegrationCodeMappings', function () {
   const org = TestStubs.Organization({
     projects,
   });
+  const invalidOrg = TestStubs.Organization({
+    projects,
+    access: [],
+  });
   const integration = TestStubs.GitHubIntegration();
   const repos = [
     TestStubs.Repository({
@@ -75,6 +80,11 @@ describe('IntegrationCodeMappings', function () {
     );
   });
 
+  afterEach(() => {
+    // Clear the fields from the GlobalModal after every test
+    ModalStore.reset();
+  });
+
   it('shows the paths', async () => {
     expect(wrapper.find('RepoName').length).toEqual(2);
     expect(wrapper.find('RepoName').at(0).text()).toEqual(repos[0].name);
@@ -85,7 +95,7 @@ describe('IntegrationCodeMappings', function () {
     const modal = await mountGlobalModal();
 
     expect(modal.find('input[name="stackRoot"]')).toHaveLength(0);
-    wrapper.find('button[aria-label="Add Mapping"]').first().simulate('click');
+    wrapper.find('button[data-test-id="add-mapping-button"]').first().simulate('click');
 
     await tick();
     modal.update();
@@ -93,6 +103,28 @@ describe('IntegrationCodeMappings', function () {
     expect(modal.find('input[name="stackRoot"]')).toHaveLength(1);
   });
 
+  it('requires permissions to click', async () => {
+    const invalidContext = TestStubs.routerContext([{organization: invalidOrg}]);
+    wrapper = mountWithTheme(
+      <IntegrationCodeMappings organization={invalidOrg} integration={integration} />,
+      invalidContext
+    );
+    const modal = await mountGlobalModal(invalidContext);
+
+    expect(modal.find('input[name="stackRoot"]')).toHaveLength(0);
+
+    const addMappingButton = wrapper
+      .find('Button[data-test-id="add-mapping-button"]')
+      .first();
+    expect(addMappingButton.prop('disabled')).toBe(true);
+    addMappingButton.simulate('click');
+
+    await tick();
+    modal.update();
+
+    expect(modal.find('input[name="stackRoot"]')).toHaveLength(0);
+  });
+
   it('create new config', async () => {
     const stackRoot = 'my/root';
     const sourceRoot = 'hey/dude';
@@ -107,7 +139,7 @@ describe('IntegrationCodeMappings', function () {
         defaultBranch,
       }),
     });
-    wrapper.find('button[aria-label="Add Mapping"]').first().simulate('click');
+    wrapper.find('button[data-test-id="add-mapping-button"]').first().simulate('click');
 
     const modal = await mountGlobalModal();
 

+ 20 - 1
tests/js/spec/views/projectOwnership.spec.jsx

@@ -58,7 +58,10 @@ describe('Project Ownership', function () {
 
   describe('codeowner action button', function () {
     it('renders button', function () {
-      org = TestStubs.Organization({features: ['integrations-codeowners']});
+      org = TestStubs.Organization({
+        features: ['integrations-codeowners'],
+        access: ['project:write'],
+      });
 
       const wrapper = mountWithTheme(
         <ProjectOwnership
@@ -71,6 +74,7 @@ describe('Project Ownership', function () {
 
       expect(wrapper.find('CodeOwnerButton').exists()).toBe(true);
     });
+
     it('clicking button opens modal', async function () {
       const wrapper = mountWithTheme(
         <ProjectOwnership
@@ -83,5 +87,20 @@ describe('Project Ownership', function () {
       wrapper.find('[data-test-id="add-codeowner-button"] button').simulate('click');
       expect(openModal).toHaveBeenCalled();
     });
+
+    it('does not render without permissions', function () {
+      org = TestStubs.Organization({features: ['integrations-codeowners'], access: []});
+
+      const wrapper = mountWithTheme(
+        <ProjectOwnership
+          params={{orgId: org.slug, projectId: project.slug}}
+          organization={org}
+          project={project}
+        />,
+        TestStubs.routerContext([{organization: org}])
+      );
+
+      expect(wrapper.find('CodeOwnerButton').exists()).toBe(false);
+    });
   });
 });