Merge pull request #7338 from mabashian/cred-plugin-test-button

Hook up Test button on Metadata step in credential plugin wizard

Reviewed-by: John Hill <johill@redhat.com>
             https://github.com/unlikelyzero
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-09-25 19:52:44 +00:00
committed by GitHub
17 changed files with 297 additions and 125 deletions

View File

@@ -14,7 +14,7 @@ import { FieldTooltip, PasswordInput } from '../../../../components/FormField';
import AnsibleSelect from '../../../../components/AnsibleSelect'; import AnsibleSelect from '../../../../components/AnsibleSelect';
import { CredentialType } from '../../../../types'; import { CredentialType } from '../../../../types';
import { required } from '../../../../util/validators'; import { required } from '../../../../util/validators';
import { CredentialPluginField } from './CredentialPlugins'; import { CredentialPluginField } from '../CredentialPlugins';
import BecomeMethodField from './BecomeMethodField'; import BecomeMethodField from './BecomeMethodField';
const FileUpload = styled(PFFileUpload)` const FileUpload = styled(PFFileUpload)`

View File

@@ -1,69 +0,0 @@
import React from 'react';
import { func, shape } from 'prop-types';
import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Wizard } from '@patternfly/react-core';
import CredentialsStep from './CredentialsStep';
import MetadataStep from './MetadataStep';
function CredentialPluginWizard({ i18n, handleSubmit, onClose }) {
const [selectedCredential] = useField('credential');
const steps = [
{
id: 1,
name: i18n._(t`Credential`),
component: <CredentialsStep />,
enableNext: !!selectedCredential.value,
},
{
id: 2,
name: i18n._(t`Metadata`),
component: <MetadataStep />,
canJumpTo: !!selectedCredential.value,
nextButtonText: i18n._(t`OK`),
},
];
return (
<Wizard
isOpen
onClose={onClose}
title={i18n._(t`External Secret Management System`)}
steps={steps}
onSave={handleSubmit}
/>
);
}
function CredentialPluginPrompt({ i18n, onClose, onSubmit, initialValues }) {
return (
<Formik
initialValues={{
credential: initialValues?.credential || null,
inputs: initialValues?.inputs || {},
}}
onSubmit={onSubmit}
>
{({ handleSubmit }) => (
<CredentialPluginWizard
handleSubmit={handleSubmit}
i18n={i18n}
onClose={onClose}
/>
)}
</Formik>
);
}
CredentialPluginPrompt.propTypes = {
onClose: func.isRequired,
onSubmit: func.isRequired,
initialValues: shape({}),
};
CredentialPluginPrompt.defaultProps = {
initialValues: {},
};
export default withI18n()(CredentialPluginPrompt);

View File

@@ -11,8 +11,8 @@ import {
Tooltip, Tooltip,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { KeyIcon } from '@patternfly/react-icons'; import { KeyIcon } from '@patternfly/react-icons';
import { FieldTooltip } from '../../../../../components/FormField'; import { FieldTooltip } from '../../../../components/FormField';
import FieldWithPrompt from '../../../../../components/FieldWithPrompt'; import FieldWithPrompt from '../../../../components/FieldWithPrompt';
import { CredentialPluginPrompt } from './CredentialPluginPrompt'; import { CredentialPluginPrompt } from './CredentialPluginPrompt';
import CredentialPluginSelected from './CredentialPluginSelected'; import CredentialPluginSelected from './CredentialPluginSelected';
@@ -55,6 +55,7 @@ function CredentialPluginInput(props) {
)} )}
> >
<Button <Button
id={`credential-${fieldOptions.id}-external-button`}
variant={ButtonVariant.control} variant={ButtonVariant.control}
aria-label={i18n._( aria-label={i18n._(
t`Populate field from an external secret management system` t`Populate field from an external secret management system`

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Formik } from 'formik'; import { Formik } from 'formik';
import { TextInput } from '@patternfly/react-core'; import { TextInput } from '@patternfly/react-core';
import { mountWithContexts } from '../../../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import CredentialPluginField from './CredentialPluginField'; import CredentialPluginField from './CredentialPluginField';
const fieldOptions = { const fieldOptions = {

View File

@@ -0,0 +1,158 @@
import React, { useCallback } from 'react';
import { func, shape } from 'prop-types';
import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
Tooltip,
Wizard,
WizardContextConsumer,
WizardFooter,
} from '@patternfly/react-core';
import CredentialsStep from './CredentialsStep';
import MetadataStep from './MetadataStep';
import { CredentialsAPI } from '../../../../../api';
import useRequest from '../../../../../util/useRequest';
import { CredentialPluginTestAlert } from '..';
function CredentialPluginWizard({ i18n, handleSubmit, onClose }) {
const [selectedCredential] = useField('credential');
const [inputValues] = useField('inputs');
const {
result: testPluginSuccess,
error: testPluginError,
request: testPluginMetadata,
} = useRequest(
useCallback(
async () =>
CredentialsAPI.test(selectedCredential.value.id, {
metadata: inputValues.value,
}),
[selectedCredential, inputValues]
),
null
);
const steps = [
{
id: 1,
name: i18n._(t`Credential`),
key: 'credential',
component: <CredentialsStep />,
enableNext: !!selectedCredential.value,
},
{
id: 2,
name: i18n._(t`Metadata`),
key: 'metadata',
component: <MetadataStep />,
canJumpTo: !!selectedCredential.value,
},
];
const CustomFooter = (
<WizardFooter>
<WizardContextConsumer>
{({ activeStep, onNext, onBack }) => (
<>
<Button
id="credential-plugin-prompt-next"
variant="primary"
onClick={onNext}
isDisabled={!selectedCredential.value}
>
{activeStep.key === 'metadata' ? i18n._(t`OK`) : i18n._(t`Next`)}
</Button>
{activeStep && activeStep.key === 'metadata' && (
<>
<Tooltip
content={i18n._(
t`Click this button to verify connection to the secret management system using the selected credential and specified inputs.`
)}
position="right"
>
<Button
id="credential-plugin-prompt-test"
variant="secondary"
onClick={() => testPluginMetadata()}
>
{i18n._(t`Test`)}
</Button>
</Tooltip>
<Button
id="credential-plugin-prompt-back"
variant="secondary"
onClick={onBack}
>
{i18n._(t`Back`)}
</Button>
</>
)}
<Button
id="credential-plugin-prompt-cancel"
variant="link"
onClick={onClose}
>
{i18n._(t`Cancel`)}
</Button>
</>
)}
</WizardContextConsumer>
</WizardFooter>
);
return (
<>
<Wizard
isOpen
onClose={onClose}
title={i18n._(t`External Secret Management System`)}
steps={steps}
onSave={handleSubmit}
footer={CustomFooter}
/>
{selectedCredential.value && (
<CredentialPluginTestAlert
credentialName={selectedCredential.value.name}
successResponse={testPluginSuccess}
errorResponse={testPluginError}
/>
)}
</>
);
}
function CredentialPluginPrompt({ i18n, onClose, onSubmit, initialValues }) {
return (
<Formik
initialValues={{
credential: initialValues?.credential || null,
inputs: initialValues?.inputs || {},
}}
onSubmit={onSubmit}
>
{({ handleSubmit }) => (
<CredentialPluginWizard
handleSubmit={handleSubmit}
i18n={i18n}
onClose={onClose}
/>
)}
</Formik>
);
}
CredentialPluginPrompt.propTypes = {
onClose: func.isRequired,
onSubmit: func.isRequired,
initialValues: shape({}),
};
CredentialPluginPrompt.defaultProps = {
initialValues: {},
};
export default withI18n()(CredentialPluginPrompt);

View File

@@ -3,15 +3,17 @@ import { act } from 'react-dom/test-utils';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../../../../../testUtils/enzymeHelpers'; } from '../../../../../../testUtils/enzymeHelpers';
import { CredentialsAPI, CredentialTypesAPI } from '../../../../../../api'; import { CredentialsAPI, CredentialTypesAPI } from '../../../../../api';
import selectedCredential from '../../../data.cyberArkCredential.json'; import selectedCredential from '../../data.cyberArkCredential.json';
import azureVaultCredential from '../../../data.azureVaultCredential.json'; import azureVaultCredential from '../../data.azureVaultCredential.json';
import hashiCorpCredential from '../../../data.hashiCorpCredential.json'; import hashiCorpCredential from '../../data.hashiCorpCredential.json';
import CredentialPluginPrompt from './CredentialPluginPrompt'; import CredentialPluginPrompt from './CredentialPluginPrompt';
jest.mock('../../../../../../api/models/Credentials'); jest.mock('../../../../../api/models/Credentials');
jest.mock('../../../../../../api/models/CredentialTypes'); jest.mock('../../../../../api/models/CredentialTypes');
CredentialsAPI.test.mockResolvedValue({});
CredentialsAPI.read.mockResolvedValue({ CredentialsAPI.read.mockResolvedValue({
data: { data: {
@@ -234,5 +236,13 @@ describe('<CredentialPluginPrompt />', () => {
wrapper.find('input#credential-secret_version').prop('value') wrapper.find('input#credential-secret_version').prop('value')
).toBe('9000'); ).toBe('9000');
}); });
test('clicking Test button makes correct call', async () => {
await act(async () => {
wrapper.find('Button[children="Test"]').simulate('click');
});
expect(CredentialsAPI.test).toHaveBeenCalledWith(1, {
metadata: { secret_path: '/foo/bar', secret_version: '9000' },
});
});
}); });
}); });

View File

@@ -3,13 +3,13 @@ import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useField } from 'formik'; import { useField } from 'formik';
import { CredentialsAPI } from '../../../../../../api'; import { CredentialsAPI } from '../../../../../api';
import CheckboxListItem from '../../../../../../components/CheckboxListItem'; import CheckboxListItem from '../../../../../components/CheckboxListItem';
import ContentError from '../../../../../../components/ContentError'; import ContentError from '../../../../../components/ContentError';
import DataListToolbar from '../../../../../../components/DataListToolbar'; import DataListToolbar from '../../../../../components/DataListToolbar';
import PaginatedDataList from '../../../../../../components/PaginatedDataList'; import PaginatedDataList from '../../../../../components/PaginatedDataList';
import { getQSConfig, parseQueryString } from '../../../../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../../../util/qs';
import useRequest from '../../../../../../util/useRequest'; import useRequest from '../../../../../util/useRequest';
const QS_CONFIG = getQSConfig('credential', { const QS_CONFIG = getQSConfig('credential', {
page: 1, page: 1,

View File

@@ -1,27 +1,22 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField, useFormikContext } from 'formik'; import { useField, useFormikContext } from 'formik';
import styled from 'styled-components'; import styled from 'styled-components';
import { Button, Form, FormGroup, Tooltip } from '@patternfly/react-core'; import { Form, FormGroup, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import { CredentialTypesAPI } from '../../../../../../api'; import { CredentialTypesAPI } from '../../../../../api';
import AnsibleSelect from '../../../../../../components/AnsibleSelect'; import AnsibleSelect from '../../../../../components/AnsibleSelect';
import ContentError from '../../../../../../components/ContentError'; import ContentError from '../../../../../components/ContentError';
import ContentLoading from '../../../../../../components/ContentLoading'; import ContentLoading from '../../../../../components/ContentLoading';
import FormField from '../../../../../../components/FormField'; import FormField from '../../../../../components/FormField';
import { FormFullWidthLayout } from '../../../../../../components/FormLayout'; import { FormFullWidthLayout } from '../../../../../components/FormLayout';
import useRequest from '../../../../../../util/useRequest'; import useRequest from '../../../../../util/useRequest';
import { required } from '../../../../../../util/validators'; import { required } from '../../../../../util/validators';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)` const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px; margin-left: 10px;
`; `;
const TestButton = styled(Button)`
margin-top: 20px;
`;
function MetadataStep({ i18n }) { function MetadataStep({ i18n }) {
const form = useFormikContext(); const form = useFormikContext();
const [selectedCredential] = useField('credential'); const [selectedCredential] = useField('credential');
@@ -65,10 +60,6 @@ function MetadataStep({ i18n }) {
fetchMetadataOptions(); fetchMetadataOptions();
}, [fetchMetadataOptions]); }, [fetchMetadataOptions]);
const testMetadata = () => {
// https://github.com/ansible/awx/issues/7126
};
if (isLoading) { if (isLoading) {
return <ContentLoading />; return <ContentLoading />;
} }
@@ -136,20 +127,6 @@ function MetadataStep({ i18n }) {
</FormFullWidthLayout> </FormFullWidthLayout>
</Form> </Form>
)} )}
<Tooltip
content={i18n._(
t`Click this button to verify connection to the secret management system using the selected credential and specified inputs.`
)}
position="right"
>
<TestButton
variant="primary"
type="submit"
onClick={() => testMetadata()}
>
{i18n._(t`Test`)}
</TestButton>
</Tooltip>
</> </>
); );
} }

View File

@@ -5,8 +5,8 @@ import { t, Trans } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import { Button, ButtonVariant, Tooltip } from '@patternfly/react-core'; import { Button, ButtonVariant, Tooltip } from '@patternfly/react-core';
import { KeyIcon } from '@patternfly/react-icons'; import { KeyIcon } from '@patternfly/react-icons';
import CredentialChip from '../../../../../components/CredentialChip'; import CredentialChip from '../../../../components/CredentialChip';
import { Credential } from '../../../../../types'; import { Credential } from '../../../../types';
const SelectedCredential = styled.div` const SelectedCredential = styled.div`
display: flex; display: flex;

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import selectedCredential from '../../data.cyberArkCredential.json'; import selectedCredential from '../data.cyberArkCredential.json';
import CredentialPluginSelected from './CredentialPluginSelected'; import CredentialPluginSelected from './CredentialPluginSelected';
describe('<CredentialPluginSelected />', () => { describe('<CredentialPluginSelected />', () => {

View File

@@ -16,7 +16,6 @@ function CredentialPluginTestAlert({
}) { }) {
const [testMessage, setTestMessage] = useState(''); const [testMessage, setTestMessage] = useState('');
const [testVariant, setTestVariant] = useState(false); const [testVariant, setTestVariant] = useState(false);
useEffect(() => { useEffect(() => {
if (errorResponse) { if (errorResponse) {
if (errorResponse?.response?.data?.inputs) { if (errorResponse?.response?.data?.inputs) {

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import CredentialPluginTestAlert from './CredentialPluginTestAlert';
describe('<CredentialPluginTestAlert />', () => {
let wrapper;
afterEach(() => {
wrapper.unmount();
});
test('renders expected content when test is successful', () => {
wrapper = mountWithContexts(
<CredentialPluginTestAlert
credentialName="Foobar"
successResponse={{}}
errorResponse={null}
/>
);
expect(wrapper.find('b#credential-plugin-test-name').text()).toBe('Foobar');
expect(wrapper.find('p#credential-plugin-test-message').text()).toBe(
'Test passed'
);
});
test('renders expected content when test fails with the expected return string formatting', () => {
wrapper = mountWithContexts(
<CredentialPluginTestAlert
credentialName="Foobar"
successResponse={null}
errorResponse={{
response: {
data: {
inputs: `HTTP 404
{"errors":["not found"]}
`,
},
},
}}
/>
);
expect(wrapper.find('b#credential-plugin-test-name').text()).toBe('Foobar');
expect(wrapper.find('p#credential-plugin-test-message').text()).toBe(
'HTTP 404: not found'
);
});
test('renders expected content when test fails without the expected return string formatting', () => {
wrapper = mountWithContexts(
<CredentialPluginTestAlert
credentialName="Foobar"
successResponse={null}
errorResponse={{
response: {
data: {
inputs: 'usernamee is not present at /secret/foo/bar/baz',
},
},
}}
/>
);
expect(wrapper.find('b#credential-plugin-test-name').text()).toBe('Foobar');
expect(wrapper.find('p#credential-plugin-test-message').text()).toBe(
'usernamee is not present at /secret/foo/bar/baz'
);
});
});

View File

@@ -18,7 +18,7 @@ import FormField from '../../../components/FormField';
import { FormFullWidthLayout } from '../../../components/FormLayout'; import { FormFullWidthLayout } from '../../../components/FormLayout';
import { required } from '../../../util/validators'; import { required } from '../../../util/validators';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
import { CredentialPluginTestAlert } from './CredentialFormFields/CredentialPlugins'; import { CredentialPluginTestAlert } from './CredentialPlugins';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)` const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px; margin-left: 10px;

View File

@@ -39,6 +39,7 @@ export default function useRequest(makeRequest, initialValue) {
async (...args) => { async (...args) => {
setIsLoading(true); setIsLoading(true);
if (isMounted.current) { if (isMounted.current) {
setResult(initialValue);
setError(null); setError(null);
} }
try { try {
@@ -56,6 +57,7 @@ export default function useRequest(makeRequest, initialValue) {
} }
} }
}, },
/* eslint-disable-next-line react-hooks/exhaustive-deps */
[makeRequest] [makeRequest]
), ),
setValue: setResult, setValue: setResult,

View File

@@ -96,6 +96,37 @@ describe('useRequest hooks', () => {
expect(wrapper.find('TestInner').prop('error')).toEqual(error); expect(wrapper.find('TestInner').prop('error')).toEqual(error);
}); });
test('should reset error/result on each request', async () => {
const error = new Error('error');
const makeRequest = throwError => {
if (throwError) {
throw error;
}
return { data: 'foo' };
};
const wrapper = mount(<Test makeRequest={makeRequest} />);
await act(async () => {
wrapper.find('TestInner').invoke('request')(true);
});
wrapper.update();
expect(wrapper.find('TestInner').prop('result')).toEqual({});
expect(wrapper.find('TestInner').prop('error')).toEqual(error);
await act(async () => {
wrapper.find('TestInner').invoke('request')();
});
wrapper.update();
expect(wrapper.find('TestInner').prop('result')).toEqual({ data: 'foo' });
expect(wrapper.find('TestInner').prop('error')).toEqual(null);
await act(async () => {
wrapper.find('TestInner').invoke('request')(true);
});
wrapper.update();
expect(wrapper.find('TestInner').prop('result')).toEqual({});
expect(wrapper.find('TestInner').prop('error')).toEqual(error);
});
test('should not update state after unmount', async () => { test('should not update state after unmount', async () => {
const makeRequest = jest.fn(); const makeRequest = jest.fn();
let resolve; let resolve;