Merge pull request #7730 from mabashian/7339-test-button

Adds support for a Test button on the credential form when the credential type is 'external'

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-08-31 18:43:00 +00:00 committed by GitHub
commit 6c9e417eb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 561 additions and 19 deletions

View File

@ -27,6 +27,10 @@ class CredentialTypes extends Base {
.concat(nextResults)
.filter(type => acceptableKinds.includes(type.kind));
}
test(id, data) {
return this.http.post(`${this.baseUrl}${id}/test/`, data);
}
}
export default CredentialTypes;

View File

@ -25,6 +25,10 @@ class Credentials extends Base {
params,
});
}
test(id, data) {
return this.http.post(`${this.baseUrl}${id}/test/`, data);
}
}
export default Credentials;

View File

@ -1,16 +1,19 @@
import React from 'react';
import React, { useState } from 'react';
import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { arrayOf, func, object, shape } from 'prop-types';
import { Form, FormGroup } from '@patternfly/react-core';
import { ActionGroup, Button, Form, FormGroup } from '@patternfly/react-core';
import FormField, { FormSubmitError } from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
import {
FormColumnLayout,
FormFullWidthLayout,
} from '../../../components/FormLayout';
import AnsibleSelect from '../../../components/AnsibleSelect';
import { required } from '../../../util/validators';
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
import { FormColumnLayout } from '../../../components/FormLayout';
import TypeInputsSubForm from './TypeInputsSubForm';
import ExternalTestModal from './ExternalTestModal';
function CredentialFormFields({
i18n,
@ -139,6 +142,7 @@ function CredentialFormFields({
}
function CredentialForm({
i18n,
credential = {},
credentialTypes,
inputSources,
@ -147,6 +151,7 @@ function CredentialForm({
submitError,
...rest
}) {
const [showExternalTestModal, setShowExternalTestModal] = useState(false);
const initialValues = {
name: credential.name || '',
description: credential.description || '',
@ -205,21 +210,61 @@ function CredentialForm({
}}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<CredentialFormFields
formik={formik}
initialValues={initialValues}
credentialTypes={credentialTypes}
{...rest}
<>
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<CredentialFormFields
formik={formik}
initialValues={initialValues}
credentialTypes={credentialTypes}
i18n={i18n}
{...rest}
/>
<FormSubmitError error={submitError} />
<FormFullWidthLayout>
<ActionGroup>
<Button
aria-label={i18n._(t`Save`)}
variant="primary"
type="button"
onClick={formik.handleSubmit}
>
{i18n._(t`Save`)}
</Button>
{formik?.values?.credential_type &&
credentialTypes[formik.values.credential_type]?.kind ===
'external' && (
<Button
aria-label={i18n._(t`Test`)}
variant="secondary"
type="button"
onClick={() => setShowExternalTestModal(true)}
isDisabled={!formik.isValid}
>
{i18n._(t`Test`)}
</Button>
)}
<Button
aria-label={i18n._(t`Cancel`)}
variant="secondary"
type="button"
onClick={onCancel}
>
{i18n._(t`Cancel`)}
</Button>
</ActionGroup>
</FormFullWidthLayout>
</FormColumnLayout>
</Form>
{showExternalTestModal && (
<ExternalTestModal
credential={credential}
credentialType={credentialTypes[formik.values.credential_type]}
credentialFormValues={formik.values}
onClose={() => setShowExternalTestModal(false)}
/>
<FormSubmitError error={submitError} />
<FormActionGroup
onCancel={onCancel}
onSubmit={formik.handleSubmit}
/>
</FormColumnLayout>
</Form>
)}
</>
)}
</Formik>
);

View File

@ -99,6 +99,9 @@ describe('<CredentialForm />', () => {
test('should display form fields on add properly', async () => {
addFieldExpects();
});
test('should hide Test button initially', () => {
expect(wrapper.find('Button[children="Test"]').length).toBe(0);
});
test('should update form values', async () => {
// name and description change
await act(async () => {
@ -221,6 +224,18 @@ describe('<CredentialForm />', () => {
'There was an error parsing the file. Please check the file formatting and try again.'
);
});
test('should show Test button when external credential type is selected', async () => {
await act(async () => {
await wrapper
.find('AnsibleSelect[id="credential_type"]')
.invoke('onChange')(null, 21);
});
wrapper.update();
expect(wrapper.find('Button[children="Test"]').length).toBe(1);
expect(wrapper.find('Button[children="Test"]').props().isDisabled).toBe(
true
);
});
test('should call handleCancel when Cancel button is clicked', async () => {
expect(onCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();

View File

@ -110,7 +110,7 @@ function CredentialField({ credentialType, fieldOptions, i18n }) {
>
<AnsibleSelect
{...subFormField}
id="credential_type"
id={`credential-${fieldOptions.id}`}
data={selectOptions}
onChange={(event, value) => {
helpers.setValue(value);

View File

@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { string, shape } from 'prop-types';
import {
Alert,
AlertActionCloseButton,
AlertGroup,
} from '@patternfly/react-core';
function CredentialPluginTestAlert({
i18n,
credentialName,
successResponse,
errorResponse,
}) {
const [testMessage, setTestMessage] = useState('');
const [testVariant, setTestVariant] = useState(false);
useEffect(() => {
if (errorResponse) {
if (errorResponse?.response?.data?.inputs) {
if (errorResponse.response.data.inputs.startsWith('HTTP')) {
const [
errorCode,
errorStr,
] = errorResponse.response.data.inputs.split('\n');
try {
const errorJSON = JSON.parse(errorStr);
setTestMessage(
`${errorCode}${
errorJSON?.errors[0] ? `: ${errorJSON.errors[0]}` : ''
}`
);
} catch {
setTestMessage(errorResponse.response.data.inputs);
}
} else {
setTestMessage(errorResponse.response.data.inputs);
}
} else {
setTestMessage(
i18n._(
t`Something went wrong with the request to test this credential and metadata.`
)
);
}
setTestVariant('danger');
} else if (successResponse) {
setTestMessage(i18n._(t`Test passed`));
setTestVariant('success');
}
}, [i18n, successResponse, errorResponse]);
return (
<AlertGroup isToast>
{testMessage && testVariant && (
<Alert
actionClose={
<AlertActionCloseButton
onClose={() => {
setTestMessage(null);
setTestVariant(null);
}}
/>
}
title={
<>
<b id="credential-plugin-test-name">{credentialName}</b>
<p id="credential-plugin-test-message">{testMessage}</p>
</>
}
variant={testVariant}
/>
)}
</AlertGroup>
);
}
CredentialPluginTestAlert.propTypes = {
credentialName: string.isRequired,
successResponse: shape({}),
errorResponse: shape({}),
};
CredentialPluginTestAlert.defaultProps = {
successResponse: null,
errorResponse: null,
};
export default withI18n()(CredentialPluginTestAlert);

View File

@ -1,2 +1,3 @@
export { default as CredentialPluginSelected } from './CredentialPluginSelected';
export { default as CredentialPluginField } from './CredentialPluginField';
export { default as CredentialPluginTestAlert } from './CredentialPluginTestAlert';

View File

@ -0,0 +1,198 @@
import React, { useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { func, shape } from 'prop-types';
import { Formik } from 'formik';
import {
Button,
Form,
FormGroup,
Modal,
Tooltip,
} from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
import AnsibleSelect from '../../../components/AnsibleSelect';
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';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
function ExternalTestModal({
i18n,
credential,
credentialType,
credentialFormValues,
onClose,
}) {
const {
result: testPluginSuccess,
error: testPluginError,
request: testPluginMetadata,
} = useRequest(
useCallback(
async values => {
const payload = {
inputs: credentialType.inputs.fields.reduce(
(filteredInputs, field) => {
filteredInputs[field.id] = credentialFormValues.inputs[field.id];
return filteredInputs;
},
{}
),
metadata: values,
};
if (credential && credential.credential_type === credentialType.id) {
return CredentialsAPI.test(credential.id, payload);
}
return CredentialTypesAPI.test(credentialType.id, payload);
},
[
credential,
credentialType.id,
credentialType.inputs.fields,
credentialFormValues.inputs,
]
),
null
);
const handleTest = async values => {
await testPluginMetadata(values);
};
return (
<>
<Formik
initialValues={credentialType.inputs.metadata.reduce(
(initialValues, field) => {
if (field.type === 'string' && field.choices) {
initialValues[field.id] = field.default || field.choices[0];
} else {
initialValues[field.id] = '';
}
return initialValues;
},
{}
)}
onSubmit={values => handleTest(values)}
>
{({ handleSubmit, setFieldValue }) => (
<Modal
title={i18n._(t`Test External Credential`)}
isOpen
onClose={() => onClose()}
variant="small"
actions={[
<Button
id="run-external-credential-test"
key="confirm"
variant="primary"
onClick={() => handleSubmit()}
>
{i18n._(t`Run`)}
</Button>,
<Button
id="cancel-external-credential-test"
key="cancel"
variant="link"
onClick={() => onClose()}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
<Form>
<FormFullWidthLayout>
{credentialType.inputs.metadata.map(field => {
const isRequired = credentialType.inputs?.required.includes(
field.id
);
if (field.type === 'string') {
if (field.choices) {
return (
<FormGroup
key={field.id}
fieldId={`credential-${field.id}`}
label={field.label}
labelIcon={
field.help_text && (
<Tooltip
content={field.help_text}
position="right"
>
<QuestionCircleIcon />
</Tooltip>
)
}
isRequired={isRequired}
>
<AnsibleSelect
name={field.id}
value={field.default}
id={`credential-${field.id}`}
data={field.choices.map(choice => {
return {
value: choice,
key: choice,
label: choice,
};
})}
onChange={(event, value) => {
setFieldValue(field.id, value);
}}
validate={isRequired ? required(null, i18n) : null}
/>
</FormGroup>
);
}
return (
<FormField
key={field.id}
id={`credential-${field.id}`}
label={field.label}
tooltip={field.help_text}
name={field.id}
type={field.multiline ? 'textarea' : 'text'}
isRequired={isRequired}
validate={isRequired ? required(null, i18n) : null}
/>
);
}
return null;
})}
</FormFullWidthLayout>
</Form>
</Modal>
)}
</Formik>
<CredentialPluginTestAlert
credentialName={credentialFormValues.name}
successResponse={testPluginSuccess}
errorResponse={testPluginError}
/>
</>
);
}
ExternalTestModal.proptype = {
credential: shape({}),
credentialType: shape({}).isRequired,
credentialFormValues: shape({}).isRequired,
onClose: func.isRequired,
};
ExternalTestModal.defaultProps = {
credential: null,
};
export default withI18n()(ExternalTestModal);

View File

@ -0,0 +1,180 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
import ExternalTestModal from './ExternalTestModal';
import credentialTypesArr from './data.credentialTypes.json';
jest.mock('../../../api/models/Credentials');
jest.mock('../../../api/models/CredentialTypes');
const credentialType = credentialTypesArr.find(
credType => credType.namespace === 'hashivault_kv'
);
const credentialFormValues = {
name: 'Foobar',
credential_type: credentialType.id,
inputs: {
api_version: 'v2',
token: '$encrypted$',
url: 'http://hashivault:8200',
},
};
const credential = {
id: 1,
name: 'A credential',
credential_type: credentialType.id,
};
describe('<ExternalTestModal />', () => {
let wrapper;
afterEach(() => wrapper.unmount());
test('should display metadata fields correctly', async () => {
wrapper = mountWithContexts(
<ExternalTestModal
credentialType={credentialType}
credentialFormValues={credentialFormValues}
onClose={jest.fn()}
/>
);
expect(wrapper.find('FormField').length).toBe(5);
expect(wrapper.find('input#credential-secret_backend').length).toBe(1);
expect(wrapper.find('input#credential-secret_path').length).toBe(1);
expect(wrapper.find('input#credential-auth_path').length).toBe(1);
expect(wrapper.find('input#credential-secret_key').length).toBe(1);
expect(wrapper.find('input#credential-secret_version').length).toBe(1);
});
test('should make the test request correctly when testing an existing credential', async () => {
wrapper = mountWithContexts(
<ExternalTestModal
credential={credential}
credentialType={credentialType}
credentialFormValues={credentialFormValues}
onClose={jest.fn()}
/>
);
await act(async () => {
wrapper.find('input#credential-secret_path').simulate('change', {
target: { value: '/secret/foo/bar/baz', name: 'secret_path' },
});
wrapper.find('input#credential-secret_key').simulate('change', {
target: { value: 'password', name: 'secret_key' },
});
});
wrapper.update();
await act(async () => {
wrapper.find('Button[children="Run"]').simulate('click');
});
expect(CredentialsAPI.test).toHaveBeenCalledWith(1, {
inputs: {
api_version: 'v2',
cacert: undefined,
role_id: undefined,
secret_id: undefined,
token: '$encrypted$',
url: 'http://hashivault:8200',
},
metadata: {
auth_path: '',
secret_backend: '',
secret_key: 'password',
secret_path: '/secret/foo/bar/baz',
secret_version: '',
},
});
});
test('should make the test request correctly when testing a new credential', async () => {
wrapper = mountWithContexts(
<ExternalTestModal
credentialType={credentialType}
credentialFormValues={credentialFormValues}
onClose={jest.fn()}
/>
);
await act(async () => {
wrapper.find('input#credential-secret_path').simulate('change', {
target: { value: '/secret/foo/bar/baz', name: 'secret_path' },
});
wrapper.find('input#credential-secret_key').simulate('change', {
target: { value: 'password', name: 'secret_key' },
});
});
wrapper.update();
await act(async () => {
wrapper.find('Button[children="Run"]').simulate('click');
});
expect(CredentialTypesAPI.test).toHaveBeenCalledWith(21, {
inputs: {
api_version: 'v2',
cacert: undefined,
role_id: undefined,
secret_id: undefined,
token: '$encrypted$',
url: 'http://hashivault:8200',
},
metadata: {
auth_path: '',
secret_backend: '',
secret_key: 'password',
secret_path: '/secret/foo/bar/baz',
secret_version: '',
},
});
});
test('should display the alert after a successful test', async () => {
CredentialTypesAPI.test.mockResolvedValue({});
wrapper = mountWithContexts(
<ExternalTestModal
credentialType={credentialType}
credentialFormValues={credentialFormValues}
onClose={jest.fn()}
/>
);
await act(async () => {
wrapper.find('input#credential-secret_path').simulate('change', {
target: { value: '/secret/foo/bar/baz', name: 'secret_path' },
});
wrapper.find('input#credential-secret_key').simulate('change', {
target: { value: 'password', name: 'secret_key' },
});
});
wrapper.update();
await act(async () => {
wrapper.find('Button[children="Run"]').simulate('click');
});
wrapper.update();
expect(wrapper.find('Alert').length).toBe(1);
expect(wrapper.find('Alert').props().variant).toBe('success');
});
test('should display the alert after a failed test', async () => {
CredentialTypesAPI.test.mockRejectedValue({
inputs: `HTTP 404
{"errors":["no handler for route '/secret/foo/bar/baz'"]}
`,
});
wrapper = mountWithContexts(
<ExternalTestModal
credentialType={credentialType}
credentialFormValues={credentialFormValues}
onClose={jest.fn()}
/>
);
await act(async () => {
wrapper.find('input#credential-secret_path').simulate('change', {
target: { value: '/secret/foo/bar/baz', name: 'secret_path' },
});
wrapper.find('input#credential-secret_key').simulate('change', {
target: { value: 'password', name: 'secret_key' },
});
});
wrapper.update();
await act(async () => {
wrapper.find('Button[children="Run"]').simulate('click');
});
wrapper.update();
expect(wrapper.find('Alert').length).toBe(1);
expect(wrapper.find('Alert').props().variant).toBe('danger');
});
});

View File

@ -1,2 +1,3 @@
export { default as mockCredentials } from './data.credentials.json';
export { default as mockCredentialType } from './data.credential_type.json';
export { default as ExternalTestModal } from './ExternalTestModal';

View File

@ -38,6 +38,9 @@ export default function useRequest(makeRequest, initialValue) {
request: useCallback(
async (...args) => {
setIsLoading(true);
if (isMounted.current) {
setError(null);
}
try {
const response = await makeRequest(...args);
if (isMounted.current) {