Add Content Signature Validation Credential field to Projects Form page and Projects Detail page

This commit is contained in:
Veda Periwal 2022-07-19 22:47:48 -07:00 committed by Rick Elrod
parent f5a2246817
commit e896dc1aa7
13 changed files with 285 additions and 20 deletions

View File

@ -7,7 +7,15 @@ class CredentialTypes extends Base {
}
async loadAllTypes(
acceptableKinds = ['machine', 'cloud', 'net', 'ssh', 'vault', 'kubernetes']
acceptableKinds = [
'machine',
'cloud',
'net',
'ssh',
'vault',
'kubernetes',
'cryptography',
]
) {
const pageSize = 200;
// The number of credential types a user can have is unlimited. In practice, it is unlikely for

View File

@ -125,6 +125,21 @@ function PromptProjectDetail({ resource }) {
}
/>
)}
{summary_fields?.signature_validation_credential?.id && (
<Detail
label={t`Content Signature Validation Credential`}
dataCy={`${prefixCy}-content-signature-validation-credential`}
value={
<CredentialChip
key={resource.summary_fields.signature_validation_credential.id}
credential={
resource.summary_fields.signature_validation_credential
}
isReadOnly
/>
}
/>
)}
{optionsList && (
<Detail
label={t`Enabled Options`}

View File

@ -18,10 +18,20 @@ function ProjectAdd() {
// the API might throw an unexpected error if our creation request
// has a zero-length string as its credential field. As a work-around,
// normalize falsey credential fields by deleting them.
delete values.credential;
} else {
values.credential = null;
} else if (typeof values.credential.id === 'number') {
values.credential = values.credential.id;
}
if (values.scm_type === 'git') {
if (!values.signature_validation_credential) {
values.signature_validation_credential = null;
} else if (
typeof values.signature_validation_credential.id === 'number'
) {
values.signature_validation_credential =
values.signature_validation_credential.id;
}
}
setFormSubmitError(null);
try {
const {

View File

@ -20,6 +20,7 @@ describe('<ProjectAdd />', () => {
scm_clean: true,
scm_track_submodules: false,
credential: 100,
signature_validation_credential: 200,
local_path: '',
organization: { id: 2, name: 'Bar' },
scm_update_on_launch: true,
@ -73,16 +74,32 @@ describe('<ProjectAdd />', () => {
},
};
const cryptographyCredentialResolve = {
data: {
results: [
{
id: 6,
name: 'GPG Public Key',
kind: 'cryptography',
},
],
count: 1,
},
};
beforeEach(async () => {
await ProjectsAPI.readOptions.mockImplementation(
() => projectOptionsResolve
);
await CredentialTypesAPI.read.mockImplementationOnce(
await CredentialTypesAPI.read.mockImplementation(
() => scmCredentialResolve
);
await CredentialTypesAPI.read.mockImplementationOnce(
await CredentialTypesAPI.read.mockImplementation(
() => insightsCredentialResolve
);
await CredentialTypesAPI.read.mockImplementation(
() => cryptographyCredentialResolve
);
});
afterEach(() => {
@ -110,6 +127,7 @@ describe('<ProjectAdd />', () => {
...projectData,
organization: 2,
default_environment: 1,
signature_validation_credential: 200,
});
});

View File

@ -124,7 +124,6 @@ function ProjectDetail({ project }) {
</TextList>
);
}
const generateLastJobTooltip = (job) => (
<>
<div>{t`MOST RECENT SYNC`}</div>
@ -149,6 +148,7 @@ function ProjectDetail({ project }) {
} else if (summary_fields?.last_job) {
job = summary_fields.last_job;
}
const getSourceControlUrlHelpText = () =>
scm_type === 'git'
? projectHelpText.githubSourceControlUrl
@ -234,6 +234,22 @@ function ProjectDetail({ project }) {
label={t`Source Control Refspec`}
value={scm_refspec}
/>
{summary_fields.signature_validation_credential && (
<Detail
label={t`Content Signature Validation Credential`}
helpText={projectHelpText.signatureValidation}
value={
<CredentialChip
key={summary_fields.signature_validation_credential.id}
credential={summary_fields.signature_validation_credential}
isReadOnly
/>
}
isEmpty={
summary_fields.signature_validation_credential.length === 0
}
/>
)}
{summary_fields.credential && (
<Detail
label={t`Source Control Credential`}
@ -244,6 +260,7 @@ function ProjectDetail({ project }) {
isReadOnly
/>
}
isEmpty={summary_fields.credential.length === 0}
/>
)}
<Detail

View File

@ -46,6 +46,11 @@ describe('<ProjectDetail />', () => {
name: 'qux',
kind: 'scm',
},
signature_validation_credential: {
id: 2000,
name: 'svc',
kind: 'cryptography',
},
last_job: {
id: 9000,
status: 'successful',
@ -78,6 +83,7 @@ describe('<ProjectDetail />', () => {
scm_delete_on_update: true,
scm_track_submodules: true,
credential: 100,
signature_validation_credential: 200,
status: 'successful',
organization: 10,
scm_update_on_launch: true,
@ -108,6 +114,10 @@ describe('<ProjectDetail />', () => {
'Source Control Credential',
`Scm: ${mockProject.summary_fields.credential.name}`
);
assertDetail(
'Content Signature Validation Credential',
`Cryptography: ${mockProject.summary_fields.signature_validation_credential.name}`
);
assertDetail(
'Cache Timeout',
`${mockProject.scm_update_cache_timeout} Seconds`

View File

@ -18,10 +18,21 @@ function ProjectEdit({ project }) {
// the API might throw an unexpected error if our creation request
// has a zero-length string as its credential field. As a work-around,
// normalize falsey credential fields by deleting them.
delete values.credential;
} else {
values.credential = null;
} else if (typeof values.credential.id === 'number') {
values.credential = values.credential.id;
}
if (values.scm_type === 'git') {
if (!values.signature_validation_credential) {
values.signature_validation_credential = null;
} else if (
typeof values.signature_validation_credential.id === 'number'
) {
values.signature_validation_credential =
values.signature_validation_credential.id;
}
}
try {
const {
data: { id },

View File

@ -21,6 +21,7 @@ describe('<ProjectEdit />', () => {
scm_clean: true,
scm_track_submodules: false,
credential: 100,
signature_validation_credential: 200,
local_path: 'bar',
organization: 2,
scm_update_on_launch: true,
@ -33,6 +34,12 @@ describe('<ProjectEdit />', () => {
credential_type_id: 5,
kind: 'insights',
},
signature_validation_credential: {
id: 200,
credential_type_id: 6,
kind: 'cryptography',
name: 'foo',
},
organization: {
id: 2,
name: 'Default',
@ -60,6 +67,7 @@ describe('<ProjectEdit />', () => {
const scmCredentialResolve = {
data: {
count: 1,
results: [
{
id: 4,
@ -72,6 +80,7 @@ describe('<ProjectEdit />', () => {
const insightsCredentialResolve = {
data: {
count: 1,
results: [
{
id: 5,
@ -82,6 +91,19 @@ describe('<ProjectEdit />', () => {
},
};
const cryptographyCredentialResolve = {
data: {
count: 1,
results: [
{
id: 6,
name: 'GPG Public Key',
kind: 'cryptography',
},
],
},
};
beforeEach(async () => {
RootAPI.readAssetVariables.mockResolvedValue({
data: {
@ -91,12 +113,15 @@ describe('<ProjectEdit />', () => {
await ProjectsAPI.readOptions.mockImplementation(
() => projectOptionsResolve
);
await CredentialTypesAPI.read.mockImplementationOnce(
await CredentialTypesAPI.read.mockImplementation(
() => scmCredentialResolve
);
await CredentialTypesAPI.read.mockImplementationOnce(
await CredentialTypesAPI.read.mockImplementation(
() => insightsCredentialResolve
);
await CredentialTypesAPI.read.mockImplementation(
() => cryptographyCredentialResolve
);
});
afterEach(() => {

View File

@ -105,6 +105,10 @@ const projectHelpTextStrings = {
you can input tags, commit hashes, and arbitrary refs. Some
commit hashes and refs may not be available unless you also
provide a custom refspec.`,
signatureValidation: t`Enable content signing to verify that the content
has remained secure when a project is synced.
If the content has been tampered with, the
job will not run.`,
options: {
clean: t`Remove any local modifications prior to performing an update.`,
delete: t`Delete the local repository in its entirety prior to

View File

@ -37,15 +37,22 @@ const fetchCredentials = async (credential) => {
results: [insightsCredentialType],
},
},
{
data: {
results: [cryptographyCredentialType],
},
},
] = await Promise.all([
CredentialTypesAPI.read({ kind: 'scm' }),
CredentialTypesAPI.read({ name: 'Insights' }),
CredentialTypesAPI.read({ kind: 'cryptography' }),
]);
if (!credential) {
return {
scm: { typeId: scmCredentialType.id },
insights: { typeId: insightsCredentialType.id },
cryptography: { typeId: cryptographyCredentialType.id },
};
}
@ -60,6 +67,13 @@ const fetchCredentials = async (credential) => {
value:
credential_type_id === insightsCredentialType.id ? credential : null,
},
cryptography: {
typeId: cryptographyCredentialType.id,
value:
credential_type_id === cryptographyCredentialType.id
? credential
: null,
},
};
};
@ -69,7 +83,9 @@ function ProjectFormFields({
project_local_paths,
formik,
setCredentials,
setSignatureValidationCredentials,
credentials,
signatureValidationCredentials,
scmTypeOptions,
setScmSubFormState,
scmSubFormState,
@ -79,6 +95,7 @@ function ProjectFormFields({
scm_branch: '',
scm_refspec: '',
credential: '',
signature_validation_credential: '',
scm_clean: false,
scm_delete_on_update: false,
scm_track_submodules: false,
@ -86,7 +103,6 @@ function ProjectFormFields({
allow_override: false,
scm_update_cache_timeout: 0,
};
const { setFieldValue, setFieldTouched } = useFormikContext();
const [scmTypeField, scmTypeMeta, scmTypeHelpers] = useField({
@ -147,6 +163,19 @@ function ProjectFormFields({
[credentials, setCredentials]
);
const handleSignatureValidationCredentialSelection = useCallback(
(type, value) => {
setSignatureValidationCredentials({
...signatureValidationCredentials,
[type]: {
...signatureValidationCredentials[type],
value,
},
});
},
[signatureValidationCredentials, setSignatureValidationCredentials]
);
const handleOrganizationUpdate = useCallback(
(value) => {
setFieldValue('organization', value);
@ -259,7 +288,13 @@ function ProjectFormFields({
git: (
<GitSubForm
credential={credentials.scm}
signature_validation_credential={
signatureValidationCredentials.cryptography
}
onCredentialSelection={handleCredentialSelection}
onSignatureValidationCredentialSelection={
handleSignatureValidationCredentialSelection
}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/>
),
@ -295,7 +330,6 @@ function ProjectFormFields({
</>
);
}
function ProjectForm({ project, submitError, ...props }) {
const { handleCancel, handleSubmit } = props;
const { summary_fields = {} } = project;
@ -307,6 +341,7 @@ function ProjectForm({ project, submitError, ...props }) {
scm_branch: '',
scm_refspec: '',
credential: '',
signature_validation_credential: '',
scm_clean: false,
scm_delete_on_update: false,
scm_track_submodules: false,
@ -318,12 +353,22 @@ function ProjectForm({ project, submitError, ...props }) {
const [credentials, setCredentials] = useState({
scm: { typeId: null, value: null },
insights: { typeId: null, value: null },
cryptography: { typeId: null, value: null },
});
const [signatureValidationCredentials, setSignatureValidationCredentials] =
useState({
scm: { typeId: null, value: null },
insights: { typeId: null, value: null },
cryptography: { typeId: null, value: null },
});
useEffect(() => {
async function fetchData() {
try {
const credentialResponse = fetchCredentials(summary_fields.credential);
const signatureValidationCredentialResponse = fetchCredentials(
summary_fields.signature_validation_credential
);
const {
data: {
actions: {
@ -335,6 +380,9 @@ function ProjectForm({ project, submitError, ...props }) {
} = await ProjectsAPI.readOptions();
setCredentials(await credentialResponse);
setSignatureValidationCredentials(
await signatureValidationCredentialResponse
);
setScmTypeOptions(choices);
} catch (error) {
setContentError(error);
@ -344,7 +392,10 @@ function ProjectForm({ project, submitError, ...props }) {
}
fetchData();
}, [summary_fields.credential]);
}, [
summary_fields.credential,
summary_fields.signature_validation_credential,
]);
if (isLoading) {
return <ContentLoading />;
@ -378,6 +429,8 @@ function ProjectForm({ project, submitError, ...props }) {
scm_update_cache_timeout: project.scm_update_cache_timeout || 0,
scm_update_on_launch: project.scm_update_on_launch || false,
scm_url: project.scm_url || '',
signature_validation_credential:
project.signature_validation_credential || '',
default_environment:
project.summary_fields?.default_environment || null,
}}
@ -392,7 +445,11 @@ function ProjectForm({ project, submitError, ...props }) {
project_local_paths={project_local_paths}
formik={formik}
setCredentials={setCredentials}
setSignatureValidationCredentials={
setSignatureValidationCredentials
}
credentials={credentials}
signatureValidationCredentials={signatureValidationCredentials}
scmTypeOptions={scmTypeOptions}
setScmSubFormState={setScmSubFormState}
scmSubFormState={scmSubFormState}

View File

@ -19,6 +19,7 @@ describe('<ProjectForm />', () => {
scm_clean: true,
scm_track_submodules: false,
credential: 100,
signature_validation_credential: 200,
organization: 2,
scm_update_on_launch: true,
scm_update_cache_timeout: 3,
@ -35,6 +36,12 @@ describe('<ProjectForm />', () => {
id: 2,
name: 'Default',
},
signature_validation_credential: {
id: 200,
credential_type_id: 6,
kind: 'cryptography',
name: 'Svc',
},
},
};
@ -58,6 +65,7 @@ describe('<ProjectForm />', () => {
const scmCredentialResolve = {
data: {
count: 1,
results: [
{
id: 4,
@ -70,6 +78,7 @@ describe('<ProjectForm />', () => {
const insightsCredentialResolve = {
data: {
count: 1,
results: [
{
id: 5,
@ -80,6 +89,19 @@ describe('<ProjectForm />', () => {
},
};
const cryptographyCredentialResolve = {
data: {
count: 1,
results: [
{
id: 6,
name: 'GPG Public Key',
kind: 'cryptography',
},
],
},
};
beforeEach(async () => {
RootAPI.readAssetVariables.mockResolvedValue({
data: {
@ -89,12 +111,15 @@ describe('<ProjectForm />', () => {
await ProjectsAPI.readOptions.mockImplementation(
() => projectOptionsResolve
);
await CredentialTypesAPI.read.mockImplementationOnce(
await CredentialTypesAPI.read.mockImplementation(
() => scmCredentialResolve
);
await CredentialTypesAPI.read.mockImplementationOnce(
await CredentialTypesAPI.read.mockImplementation(
() => insightsCredentialResolve
);
await CredentialTypesAPI.read.mockImplementation(
() => cryptographyCredentialResolve
);
});
afterEach(() => {
@ -153,9 +178,17 @@ describe('<ProjectForm />', () => {
expect(
wrapper.find('FormGroup[label="Source Control Refspec"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[label="Content Signature Validation Credential"]')
.length
).toBe(1);
expect(
wrapper.find('FormGroup[label="Source Control Credential"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[label="Content Signature Validation Credential"]')
.length
).toBe(1);
expect(wrapper.find('FormGroup[label="Options"]').length).toBe(1);
});
@ -177,21 +210,52 @@ describe('<ProjectForm />', () => {
id: 1,
name: 'organization',
});
wrapper.find('CredentialLookup').invoke('onBlur')();
wrapper.find('CredentialLookup').invoke('onChange')({
wrapper
.find('CredentialLookup[label="Source Control Credential"]')
.invoke('onBlur')();
wrapper
.find('CredentialLookup[label="Source Control Credential"]')
.invoke('onChange')({
id: 10,
name: 'credential',
});
wrapper
.find(
'CredentialLookup[label="Content Signature Validation Credential"]'
)
.invoke('onBlur')();
wrapper
.find(
'CredentialLookup[label="Content Signature Validation Credential"]'
)
.invoke('onChange')({
id: 20,
name: 'signature_validation_credential',
});
});
wrapper.update();
expect(wrapper.find('OrganizationLookup').prop('value')).toEqual({
id: 1,
name: 'organization',
});
expect(wrapper.find('CredentialLookup').prop('value')).toEqual({
expect(
wrapper
.find('CredentialLookup[label="Source Control Credential"]')
.prop('value')
).toEqual({
id: 10,
name: 'credential',
});
expect(
wrapper
.find(
'CredentialLookup[label="Content Signature Validation Credential"]'
)
.prop('value')
).toEqual({
id: 20,
name: 'signature_validation_credential',
});
});
test('should display insights credential lookup when source control type is "insights"', async () => {
@ -358,7 +422,9 @@ describe('<ProjectForm />', () => {
});
test('should display ContentError on throw', async () => {
CredentialTypesAPI.read = () => Promise.reject(new Error());
CredentialTypesAPI.read.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />

View File

@ -1,6 +1,8 @@
import 'styled-components/macro';
import React from 'react';
import React, { useCallback } from 'react';
import { t } from '@lingui/macro';
import { useFormikContext } from 'formik';
import CredentialLookup from 'components/Lookup/CredentialLookup';
import FormField from 'components/FormField';
import getDocsBaseUrl from 'util/getDocsBaseUrl';
import { useConfig } from 'contexts/Config';
@ -16,9 +18,22 @@ import projectHelpStrings from '../Project.helptext';
const GitSubForm = ({
credential,
signature_validation_credential,
onCredentialSelection,
onSignatureValidationCredentialSelection,
scmUpdateOnLaunch,
}) => {
const { setFieldValue, setFieldTouched } = useFormikContext();
const onCredentialChange = useCallback(
(value) => {
onSignatureValidationCredentialSelection('cryptography', value);
setFieldValue('signature_validation_credential', value);
setFieldTouched('signature_validation_credential', true, false);
},
[onSignatureValidationCredentialSelection, setFieldValue, setFieldTouched]
);
const docsURL = `${getDocsBaseUrl(
useConfig()
)}/html/userguide/projects.html#manage-playbooks-using-source-control`;
@ -35,6 +50,13 @@ const GitSubForm = ({
tooltipMaxWidth="400px"
tooltip={projectHelpStrings.sourceControlRefspec(docsURL)}
/>
<CredentialLookup
credentialTypeId={signature_validation_credential.typeId}
label={t`Content Signature Validation Credential`}
onChange={onCredentialChange}
value={signature_validation_credential.value}
tooltip={projectHelpStrings.signatureValidation}
/>
<ScmCredentialFormField
credential={credential}
onCredentialSelection={onCredentialSelection}

View File

@ -145,6 +145,7 @@ export const Project = shape({
summary_fields: shape({
organization: Organization,
credential: Credential,
signature_validation_credential: Credential,
last_job: shape({}),
last_update: shape({}),
created_by: shape({}),
@ -163,6 +164,7 @@ export const Project = shape({
scm_delete_on_update: bool,
scm_track_submodules: bool,
credential: number,
signature_validation_credential: number,
status: oneOf([
'new',
'pending',