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 { CredentialType } from '../../../../types';
import { required } from '../../../../util/validators';
import { CredentialPluginField } from './CredentialPlugins';
import { CredentialPluginField } from '../CredentialPlugins';
import BecomeMethodField from './BecomeMethodField';
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,
} from '@patternfly/react-core';
import { KeyIcon } from '@patternfly/react-icons';
import { FieldTooltip } from '../../../../../components/FormField';
import FieldWithPrompt from '../../../../../components/FieldWithPrompt';
import { FieldTooltip } from '../../../../components/FormField';
import FieldWithPrompt from '../../../../components/FieldWithPrompt';
import { CredentialPluginPrompt } from './CredentialPluginPrompt';
import CredentialPluginSelected from './CredentialPluginSelected';
@@ -55,6 +55,7 @@ function CredentialPluginInput(props) {
)}
>
<Button
id={`credential-${fieldOptions.id}-external-button`}
variant={ButtonVariant.control}
aria-label={i18n._(
t`Populate field from an external secret management system`

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Formik } from 'formik';
import { TextInput } from '@patternfly/react-core';
import { mountWithContexts } from '../../../../../../testUtils/enzymeHelpers';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import CredentialPluginField from './CredentialPluginField';
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 {
mountWithContexts,
waitForElement,
} from '../../../../../../../testUtils/enzymeHelpers';
import { CredentialsAPI, CredentialTypesAPI } from '../../../../../../api';
import selectedCredential from '../../../data.cyberArkCredential.json';
import azureVaultCredential from '../../../data.azureVaultCredential.json';
import hashiCorpCredential from '../../../data.hashiCorpCredential.json';
} from '../../../../../../testUtils/enzymeHelpers';
import { CredentialsAPI, CredentialTypesAPI } from '../../../../../api';
import selectedCredential from '../../data.cyberArkCredential.json';
import azureVaultCredential from '../../data.azureVaultCredential.json';
import hashiCorpCredential from '../../data.hashiCorpCredential.json';
import CredentialPluginPrompt from './CredentialPluginPrompt';
jest.mock('../../../../../../api/models/Credentials');
jest.mock('../../../../../../api/models/CredentialTypes');
jest.mock('../../../../../api/models/Credentials');
jest.mock('../../../../../api/models/CredentialTypes');
CredentialsAPI.test.mockResolvedValue({});
CredentialsAPI.read.mockResolvedValue({
data: {
@@ -234,5 +236,13 @@ describe('<CredentialPluginPrompt />', () => {
wrapper.find('input#credential-secret_version').prop('value')
).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 { t } from '@lingui/macro';
import { useField } from 'formik';
import { CredentialsAPI } from '../../../../../../api';
import CheckboxListItem from '../../../../../../components/CheckboxListItem';
import ContentError from '../../../../../../components/ContentError';
import DataListToolbar from '../../../../../../components/DataListToolbar';
import PaginatedDataList from '../../../../../../components/PaginatedDataList';
import { getQSConfig, parseQueryString } from '../../../../../../util/qs';
import useRequest from '../../../../../../util/useRequest';
import { CredentialsAPI } from '../../../../../api';
import CheckboxListItem from '../../../../../components/CheckboxListItem';
import ContentError from '../../../../../components/ContentError';
import DataListToolbar from '../../../../../components/DataListToolbar';
import PaginatedDataList from '../../../../../components/PaginatedDataList';
import { getQSConfig, parseQueryString } from '../../../../../util/qs';
import useRequest from '../../../../../util/useRequest';
const QS_CONFIG = getQSConfig('credential', {
page: 1,

View File

@@ -1,27 +1,22 @@
import React, { useCallback, useEffect } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField, useFormikContext } from 'formik';
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 { CredentialTypesAPI } from '../../../../../../api';
import AnsibleSelect from '../../../../../../components/AnsibleSelect';
import ContentError from '../../../../../../components/ContentError';
import ContentLoading from '../../../../../../components/ContentLoading';
import FormField from '../../../../../../components/FormField';
import { FormFullWidthLayout } from '../../../../../../components/FormLayout';
import useRequest from '../../../../../../util/useRequest';
import { required } from '../../../../../../util/validators';
import { CredentialTypesAPI } from '../../../../../api';
import AnsibleSelect from '../../../../../components/AnsibleSelect';
import ContentError from '../../../../../components/ContentError';
import ContentLoading from '../../../../../components/ContentLoading';
import FormField from '../../../../../components/FormField';
import { FormFullWidthLayout } from '../../../../../components/FormLayout';
import useRequest from '../../../../../util/useRequest';
import { required } from '../../../../../util/validators';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
const TestButton = styled(Button)`
margin-top: 20px;
`;
function MetadataStep({ i18n }) {
const form = useFormikContext();
const [selectedCredential] = useField('credential');
@@ -65,10 +60,6 @@ function MetadataStep({ i18n }) {
fetchMetadataOptions();
}, [fetchMetadataOptions]);
const testMetadata = () => {
// https://github.com/ansible/awx/issues/7126
};
if (isLoading) {
return <ContentLoading />;
}
@@ -136,20 +127,6 @@ function MetadataStep({ i18n }) {
</FormFullWidthLayout>
</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 { Button, ButtonVariant, Tooltip } from '@patternfly/react-core';
import { KeyIcon } from '@patternfly/react-icons';
import CredentialChip from '../../../../../components/CredentialChip';
import { Credential } from '../../../../../types';
import CredentialChip from '../../../../components/CredentialChip';
import { Credential } from '../../../../types';
const SelectedCredential = styled.div`
display: flex;

View File

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

View File

@@ -16,7 +16,6 @@ function CredentialPluginTestAlert({
}) {
const [testMessage, setTestMessage] = useState('');
const [testVariant, setTestVariant] = useState(false);
useEffect(() => {
if (errorResponse) {
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 { required } from '../../../util/validators';
import useRequest from '../../../util/useRequest';
import { CredentialPluginTestAlert } from './CredentialFormFields/CredentialPlugins';
import { CredentialPluginTestAlert } from './CredentialPlugins';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;

View File

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

View File

@@ -96,6 +96,37 @@ describe('useRequest hooks', () => {
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 () => {
const makeRequest = jest.fn();
let resolve;