diff --git a/client/src/components/InputsModal/AuthSettings.ts b/client/src/components/InputsModal/AuthSettings.ts new file mode 100644 index 000000000..871e0af96 --- /dev/null +++ b/client/src/components/InputsModal/AuthSettings.ts @@ -0,0 +1,350 @@ +import { TestInput } from '~/models/testSuiteModels'; + +export type AuthType = 'public' | 'symmetric' | 'asymmetric' | 'backend_services'; + +export const authSettings = { + public: [ + 'use_discovery', + 'client_id', + 'requested_scopes', + 'pkce_support', + 'pkce_code_challenge_method', + 'auth_request_method', + 'auth_url', + 'token_url', + ], + symmetric: [ + 'use_discovery', + 'client_id', + 'client_secret', + 'requested_scopes', + 'pkce_support', + 'pkce_code_challenge_method', + 'auth_request_method', + 'auth_url', + 'token_url', + ], + asymmetric: [ + 'use_discovery', + 'client_id', + 'requested_scopes', + 'pkce_support', + 'pkce_code_challenge_method', + 'auth_request_method', + 'encryption_algorithm', + 'jwks', + 'kid', + 'auth_url', + 'token_url', + ], + backend_services: [ + 'use_discovery', + 'client_id', + 'requested_scopes', + 'encryption_algorithm', + 'jwks', + 'kid', + 'token_url', + ], +}; + +export const getAuthFields = ( + authType: AuthType, + authValues: Map, + components: TestInput[] +): TestInput[] => { + const fields = [ + { + name: 'use_discovery', + type: 'checkbox', + title: 'Populate fields from discovery', + optional: true, + default: 'true', + }, + { + name: 'auth_url', + title: 'Authorization URL', + description: "URL of the server's authorization endpoint", + optional: true, + hide: authValues?.get('use_discovery') === 'true', + }, + { + name: 'token_url', + title: 'Token URL', + description: "URL of the authorization server's token endpoint", + optional: true, + hide: authValues?.get('use_discovery') === 'true', + }, + { + name: 'requested_scopes', + title: 'Scopes', + description: 'OAuth 2.0 scopes needed to enable all required functionality', + }, + { + name: 'client_id', + title: 'Client ID', + description: 'Client ID provided during registration of Inferno', + }, + { + name: 'client_secret', + title: 'Client Secret', + description: 'Client secret provided during registration of Inferno', + }, + { + name: 'pkce_support', + type: 'radio', + title: 'Proof Key for Code Exchange (PKCE)', + options: { + list_options: [ + { + label: 'Enabled', + value: 'enabled', + }, + { + label: 'Disabled', + value: 'disabled', + }, + ], + }, + }, + { + name: 'pkce_code_challenge_method', + type: 'radio', + title: 'PKCE Code Challenge Method', + optional: true, + options: { + list_options: [ + { + label: 'S256', + value: 'S256', + }, + { + label: 'Plain', + value: 'plain', + }, + ], + }, + hide: authValues ? authValues.get('pkce_support') === 'disabled' : false, + }, + { + name: 'auth_request_method', + type: 'radio', + title: 'Authorization Request Method', + options: { + list_options: [ + { + label: 'GET', + value: 'GET', + }, + { + label: 'POST', + value: 'POST', + }, + ], + }, + }, + { + name: 'encryption_algorithm', + type: 'radio', + title: 'Encryption Algorithm', + options: { + list_options: [ + { + label: 'ES384', + value: 'ES384', + }, + { + label: 'RS384', + value: 'RS384', + }, + ], + }, + }, + { + name: 'kid', + title: 'Key ID (kid)', + description: + 'Key ID of the JWKS private key used to sign the client assertion. If blank, the first key for the selected encryption algorithm will be used.', + optional: true, + }, + { + name: 'jwks', + type: 'textarea', + title: 'JWKS', + description: + "The JWKS (including private keys) which will be used to sign the client assertion. If blank, Inferno's default JWKS will be used.", + optional: true, + }, + ] as TestInput[]; + + // If the requirement contains custom fields, replace default fields + const fieldsToUpdate = components.map((component) => component.name); + fields.forEach((field, i) => { + if (fieldsToUpdate.includes(field.name)) { + const customComponent = components.find((component) => component.name === field.name); + fields[i] = { ...field, ...customComponent }; + } + }); + + // Remove extra properties based on auth type or hide if no settings + const typeValues = authSettings[authType]; + if (authSettings && authType) { + return fields.filter((field) => typeValues.includes(field.name)); + } + fields.forEach((field) => (field.hide = field.hide || !typeValues.includes(field.name))); + return fields; +}; + +export const accessSettings = { + public: ['access_token', 'refresh_token', 'client_id', 'token_url', 'issue_time', 'expires_in'], + symmetric: [ + 'access_token', + 'refresh_token', + 'client_id', + 'client_secret', + 'token_url', + 'issue_time', + 'expires_in', + ], + asymmetric: [ + 'access_token', + 'refresh_token', + 'client_id', + 'token_url', + 'encryption_algorithm', + 'jwks', + 'kid', + 'issue_time', + 'expires_in', + ], + backend_services: [ + 'access_token', + 'client_id', + 'token_url', + 'encryption_algorithm', + 'jwks', + 'kid', + 'issue_time', + 'expires_in', + ], +}; + +export const getAccessFields = ( + authType: AuthType, + accessValues: Map, + components: TestInput[] +): TestInput[] => { + const fields = [ + { + name: 'access_token', + title: 'Access Token', + }, + { + name: 'refresh_token', + title: 'Refresh Token (will automatically refresh if available)', + optional: true, + }, + { + name: 'client_id', + title: 'Client ID', + description: 'Client ID provided during registration of Inferno', + optional: true, + hide: !accessValues.get('refresh_token'), + }, + { + name: 'client_secret', + title: 'Client Secret', + description: 'Client secret provided during registration of Inferno', + optional: true, + hide: !accessValues.get('refresh_token'), + }, + { + name: 'token_url', + title: 'Token URL', + description: "URL of the authorization server's token endpoint", + optional: true, + hide: !accessValues.get('refresh_token'), + }, + { + name: 'encryption_algorithm', + type: 'radio', + title: 'Encryption Algorithm', + options: { + list_options: [ + { + label: 'ES384', + value: 'ES384', + }, + { + label: 'RS384', + value: 'RS384', + }, + ], + }, + optional: true, + hide: + !accessValues.get('refresh_token') || + (authType === 'backend_services' && !accessValues.get('access_token')), + }, + { + name: 'kid', + title: 'Key ID (kid)', + description: + 'Key ID of the JWKS private key used to sign the client assertion. If blank, the first key for the selected encryption algorithm will be used.', + optional: true, + hide: + authType === 'backend_services' + ? !accessValues.get('access_token') + : !accessValues.get('refresh_token'), + }, + { + name: 'jwks', + type: 'textarea', + title: 'JWKS', + description: + "The JWKS (including private keys) which will be used to sign the client assertion. If blank, Inferno's default JWKS will be used.", + optional: true, + hide: + authType === 'backend_services' + ? !accessValues.get('access_token') + : !accessValues.get('refresh_token'), + }, + { + name: 'issue_time', + title: 'Access Token Issue Time', + description: 'The time that the access token was issued in iso8601 format', + optional: true, + hide: + authType === 'backend_services' + ? !accessValues.get('access_token') + : !accessValues.get('refresh_token'), + }, + { + name: 'expires_in', + title: 'Token Lifetime', + description: 'The lifetime of the access token in seconds', + optional: true, + hide: + authType === 'backend_services' + ? !accessValues.get('access_token') + : !accessValues.get('refresh_token'), + }, + ] as TestInput[]; + + // If the requirement contains custom fields, replace default fields + const fieldsToUpdate = components.map((component) => component.name); + fields.forEach((field, i) => { + if (fieldsToUpdate.includes(field.name)) { + const customComponent = components.find((component) => component.name === field.name); + fields[i] = { ...field, ...customComponent }; + } + }); + + // Remove extra properties based on auth type or hide if no settings + const typeValues = accessSettings[authType]; + if (accessSettings && authType) { + return fields.filter((field) => typeValues.includes(field.name)); + } + fields.forEach((field) => (field.hide = field.hide || !typeValues.includes(field.name))); + return fields; +}; diff --git a/client/src/components/InputsModal/AuthTypeSelector.tsx b/client/src/components/InputsModal/AuthTypeSelector.tsx new file mode 100644 index 000000000..124b7cb38 --- /dev/null +++ b/client/src/components/InputsModal/AuthTypeSelector.tsx @@ -0,0 +1,68 @@ +import React, { FC } from 'react'; +import { TestInput } from '~/models/testSuiteModels'; +import InputCombobox from './InputCombobox'; + +export interface InputAccessProps { + requirement: TestInput; + index: number; + inputsMap: Map; + setInputsMap: (map: Map, edited?: boolean) => void; +} + +const AuthTypeSelector: FC = ({ + requirement, + index, + inputsMap, + setInputsMap, +}) => { + const selectorSettings = requirement.options?.components + ? requirement.options?.components[0] + : // Default auth type settings + { + name: 'auth_type', + default: 'public', + }; + + const selectorModel: TestInput = { + name: 'auth_type', + type: 'select', + title: `${requirement.name} Auth Type`, + description: requirement.description, + default: selectorSettings.default || 'public', + optional: selectorSettings.optional, + locked: selectorSettings.locked, + options: { + list_options: [ + { + label: 'Public', + value: 'public', + }, + { + label: 'Confidential Symmetric', + value: 'symmetric', + }, + { + label: 'Confidential Asymmetric', + value: 'asymmetric', + }, + { + label: 'Backend Services', + value: 'backend_services', + }, + ], + }, + }; + + return ( + + ); +}; + +export default AuthTypeSelector; diff --git a/client/src/components/InputsModal/FieldLabel.tsx b/client/src/components/InputsModal/FieldLabel.tsx index a23912590..e12838708 100644 --- a/client/src/components/InputsModal/FieldLabel.tsx +++ b/client/src/components/InputsModal/FieldLabel.tsx @@ -12,9 +12,10 @@ export interface FieldLabelProps { const FieldLabel: FC = ({ requirement, isMissingInput = false }) => { const { classes } = useStyles(); - const fieldLabelText = requirement.title || requirement.name; + const fieldLabelText = (requirement.title || requirement.name) as string; - const requiredLabel = !requirement.optional ? ' (required)' : ''; + // Radio buttons will always have an input value + const requiredLabel = !requirement.optional && requirement.type !== 'radio' ? ' (required)' : ''; const lockedIcon = requirement.locked && ( diff --git a/client/src/components/InputsModal/InputAccess.tsx b/client/src/components/InputsModal/InputAccess.tsx new file mode 100644 index 000000000..7e0830c8e --- /dev/null +++ b/client/src/components/InputsModal/InputAccess.tsx @@ -0,0 +1,147 @@ +import React, { FC, useEffect } from 'react'; +import { Card, CardContent, InputLabel, List, ListItem, Typography } from '@mui/material'; +import { Auth, TestInput } from '~/models/testSuiteModels'; +import { AuthType, getAccessFields } from '~/components/InputsModal/AuthSettings'; +import FieldLabel from '~/components/InputsModal/FieldLabel'; +import InputFields from '~/components/InputsModal/InputFields'; +import useStyles from './styles'; +import AuthTypeSelector from './AuthTypeSelector'; + +export interface InputAccessProps { + requirement: TestInput; + index: number; + inputsMap: Map; + setInputsMap: (map: Map, edited?: boolean) => void; +} + +const InputAccess: FC = ({ requirement, index, inputsMap, setInputsMap }) => { + const { classes } = useStyles(); + const [accessValues, setAccessValues] = React.useState>(new Map()); + const [accessValuesPopulated, setAccessValuesPopulated] = React.useState(false); + + // Default auth type settings + const [authType, setAuthType] = React.useState( + requirement.options?.components + ? (requirement.options?.components[0].default as string) + : 'public' + ); + const [accessFields, setAccessFields] = React.useState( + getAccessFields(authType as AuthType, accessValues, requirement.options?.components || []) + ); + + useEffect(() => { + // Set defaults on radio buttons + // This is necessary because radio buttons with no preset defaults will still cause + // missing input errors + setAccessFields( + accessFields.map((field) => { + if ( + field.type === 'radio' && + !field.default && + !field.value && + field.options?.list_options + ) { + field.default = field.options?.list_options[0].value; + } + return field; + }) + ); + + const combinedStartingValues = getStartingValues(); + + // Populate accessValues on mount + accessValues.set('auth_type', authType); + accessFields.forEach((field: TestInput) => { + accessValues.set(field.name, combinedStartingValues[field.name as keyof Auth] || ''); + }); + setAccessValuesPopulated(true); + + // Trigger change on mount for default values + const accessValuesCopy = new Map(accessValues); + setAccessValues(accessValuesCopy); + }, []); + + useEffect(() => { + // Recalculate hidden fields + setAccessFields( + getAccessFields(authType as AuthType, accessValues, requirement.options?.components || []) + ); + + // Update inputsMap while maintaining hidden values + if (accessValuesPopulated) { + const combinedStartingValues = getStartingValues(); + const accessValuesObject = Object.fromEntries(accessValues) as Auth; + const combinedValues = { ...combinedStartingValues, ...accessValuesObject }; + const stringifiedAccessValues = JSON.stringify(combinedValues); + inputsMap.set(requirement.name, stringifiedAccessValues); + setInputsMap(new Map(inputsMap)); + } + }, [accessValues]); + + const getStartingValues = () => { + // Pre-populate values from AuthFields, requirement, and inputsMap in order of precedence + const fieldDefaultValues = accessFields.reduce( + (acc, field) => ({ ...acc, [field.name]: field.default }), + {} + ) as Auth; + const requirementDefaultValues = + requirement.default && typeof requirement.default === 'string' + ? (JSON.parse(requirement.default) as Auth) + : {}; + const requirementStartingValues = + requirement.value && typeof requirement.value === 'string' + ? (JSON.parse(requirement.value) as Auth) + : {}; + const inputsMapValues = inputsMap.get(requirement.name) + ? (JSON.parse(inputsMap.get(requirement.name) as string) as Auth) + : {}; + return { + ...fieldDefaultValues, + ...requirementDefaultValues, + ...requirementStartingValues, + ...inputsMapValues, + } as Auth; + }; + + const updateAuthType = (map: Map) => { + setAuthType(map.get('auth_type') as string); + setAccessValues(map); + }; + + return ( + + + + + + + {requirement.description && ( + + {requirement.description} + + )} + + + + + + + + ); +}; + +export default InputAccess; diff --git a/client/src/components/InputsModal/InputAuth.tsx b/client/src/components/InputsModal/InputAuth.tsx new file mode 100644 index 000000000..749e81c94 --- /dev/null +++ b/client/src/components/InputsModal/InputAuth.tsx @@ -0,0 +1,133 @@ +import React, { FC, useEffect } from 'react'; +import { Box, List, ListItem, Typography } from '@mui/material'; +import { Auth, TestInput } from '~/models/testSuiteModels'; +import InputFields from './InputFields'; +import useStyles from './styles'; +import { AuthType, getAuthFields } from './AuthSettings'; +import AuthTypeSelector from './AuthTypeSelector'; + +export interface InputAuthProps { + requirement: TestInput; + index: number; + inputsMap: Map; + setInputsMap: (map: Map, edited?: boolean) => void; +} + +const InputAuth: FC = ({ requirement, index, inputsMap, setInputsMap }) => { + const { classes } = useStyles(); + const [authValues, setAuthValues] = React.useState>(new Map()); + const [authValuesPopulated, setAuthValuesPopulated] = React.useState(false); + + // Default auth type settings + const [authType, setAuthType] = React.useState( + requirement.options?.components + ? (requirement.options?.components[0].default as string) + : 'public' + ); + + const [authFields, setAuthFields] = React.useState( + getAuthFields(authType as AuthType, authValues, requirement.options?.components || []) + ); + + useEffect(() => { + // Set defaults on radio buttons + // This is necessary because radio buttons with no preset defaults will still cause + // missing input errors + setAuthFields( + authFields.map((field) => { + if ( + field.type === 'radio' && + !field.default && + !field.value && + field.options?.list_options + ) { + field.default = field.options?.list_options[0].value; + } + return field; + }) + ); + + const combinedStartingValues = getStartingValues(); + + // Populate authValues on mount + authValues.set('auth_type', authType); + authFields.forEach((field: TestInput) => { + authValues.set(field.name, combinedStartingValues[field.name as keyof Auth] || ''); + }); + + setAuthValuesPopulated(true); + + // Trigger change on mount for default values + const authValuesCopy = new Map(authValues); + setAuthValues(authValuesCopy); + }, []); + + useEffect(() => { + // Recalculate hidden fields + setAuthFields( + getAuthFields(authType as AuthType, authValues, requirement.options?.components || []) + ); + + // Update inputsMap + if (authValuesPopulated) { + const stringifiedAuthValues = JSON.stringify(Object.fromEntries(authValues)); + inputsMap.set(requirement.name, stringifiedAuthValues); + setInputsMap(new Map(inputsMap)); + } + }, [authValues]); + + const getStartingValues = () => { + // Pre-populate values from AuthFields, requirement, and inputsMap in order of precedence + const fieldDefaultValues = authFields.reduce( + (acc, field) => ({ ...acc, [field.name]: field.default }), + {} + ) as Auth; + const requirementDefaultValues = + requirement.default && typeof requirement.default === 'string' + ? (JSON.parse(requirement.default) as Auth) + : {}; + const requirementStartingValues = + requirement.value && typeof requirement.value === 'string' + ? (JSON.parse(requirement.value) as Auth) + : {}; + const inputsMapValues = inputsMap.get(requirement.name) + ? (JSON.parse(inputsMap.get(requirement.name) as string) as Auth) + : {}; + + return { + ...fieldDefaultValues, + ...requirementDefaultValues, + ...requirementStartingValues, + ...inputsMapValues, + } as Auth; + }; + + const updateAuthType = (map: Map) => { + setAuthType(map.get('auth_type') as string); + setAuthValues(map); + }; + + return ( + + + {requirement.description && ( + + {requirement.description} + + )} + + + + + + + ); +}; + +export default InputAuth; diff --git a/client/src/components/InputsModal/InputCheckboxGroup.tsx b/client/src/components/InputsModal/InputCheckboxGroup.tsx index 15343ba64..cbca11f72 100644 --- a/client/src/components/InputsModal/InputCheckboxGroup.tsx +++ b/client/src/components/InputsModal/InputCheckboxGroup.tsx @@ -107,7 +107,7 @@ const InputCheckboxGroup: FC = ({ {requirement.description && ( - + {requirement.description} )} diff --git a/client/src/components/InputsModal/InputCombobox.tsx b/client/src/components/InputsModal/InputCombobox.tsx new file mode 100644 index 000000000..0db487621 --- /dev/null +++ b/client/src/components/InputsModal/InputCombobox.tsx @@ -0,0 +1,89 @@ +import React, { FC } from 'react'; +import { + Autocomplete, + FormControl, + FormLabel, + ListItem, + TextField, + Typography, +} from '@mui/material'; +import { InputOption, TestInput } from '~/models/testSuiteModels'; +import FieldLabel from './FieldLabel'; +import useStyles from './styles'; + +export interface InputComboboxProps { + requirement: TestInput; + index: number; + inputsMap: Map; + setInputsMap: (map: Map, edited?: boolean) => void; + disableClear?: boolean; +} + +const InputCombobox: FC = ({ + requirement, + index, + inputsMap, + setInputsMap, + disableClear, +}) => { + const { classes } = useStyles(); + + const getDefaultValue = (): InputOption | null => { + const options = requirement.options?.list_options; + if (!options) return null; + + let defaultValue = options[0]; // set to first option if no default provided + if (requirement.default && typeof requirement.default === 'string') { + const discoveredOption = options.find((option) => option.value === requirement.default); + if (discoveredOption) defaultValue = discoveredOption; + } + return defaultValue; + }; + + return ( + + + + + + {requirement.description && ( + + {requirement.description} + + )} + option.value === value.value} + renderInput={(params) => ( + + )} + onChange={(event, newValue: InputOption | null) => { + const value = newValue?.value; + inputsMap.set(requirement.name, value); + setInputsMap(new Map(inputsMap)); + }} + /> + + + ); +}; + +export default InputCombobox; diff --git a/client/src/components/InputsModal/InputFields.tsx b/client/src/components/InputsModal/InputFields.tsx index df9b4cbcd..366de7553 100644 --- a/client/src/components/InputsModal/InputFields.tsx +++ b/client/src/components/InputsModal/InputFields.tsx @@ -5,6 +5,10 @@ import InputOAuthCredentials from '~/components/InputsModal/InputOAuthCredential import InputCheckboxGroup from '~/components/InputsModal/InputCheckboxGroup'; import InputRadioGroup from '~/components/InputsModal/InputRadioGroup'; import InputTextField from '~/components/InputsModal/InputTextField'; +import InputAuth from '~/components/InputsModal/InputAuth'; +import InputSingleCheckbox from '~/components/InputsModal/InputSingleCheckbox'; +import InputCombobox from '~/components/InputsModal/InputCombobox'; +import InputAccess from '~/components/InputsModal/InputAccess'; export interface InputFieldsProps { inputs: TestInput[]; @@ -16,57 +20,99 @@ const InputFields: FC = ({ inputs, inputsMap, setInputsMap }) return ( {inputs.map((requirement: TestInput, index: number) => { - switch (requirement.type) { - case 'auth_info': - return ( - setInputsMap(newInputsMap)} - key={`input-${index}`} - /> - ); - case 'oauth_credentials': - return ( - setInputsMap(newInputsMap)} - key={`input-${index}`} - /> - ); - case 'checkbox': - return ( - setInputsMap(newInputsMap, editStatus)} - key={`input-${index}`} - /> - ); - case 'radio': - return ( - setInputsMap(newInputsMap)} - key={`input-${index}`} - /> - ); - default: - return ( - setInputsMap(newInputsMap)} - key={`input-${index}`} - /> - ); + if (!requirement.hide) { + switch (requirement.type) { + case 'auth_info': + if (requirement.options?.mode === 'auth') { + return ( + setInputsMap(newInputsMap)} + key={`input-${index}`} + /> + ); + } + return ( + setInputsMap(newInputsMap)} + key={`input-${index}`} + /> + ); + case 'oauth_credentials': + return ( + setInputsMap(newInputsMap)} + key={`input-${index}`} + /> + ); + case 'checkbox': + if (requirement.options?.list_options?.length) { + return ( + + setInputsMap(newInputsMap, editStatus) + } + key={`input-${index}`} + /> + ); + } else { + // if no options listed then assume single checkbox input + return ( + + setInputsMap(newInputsMap, editStatus) + } + key={`input-${index}`} + /> + ); + } + case 'radio': + return ( + setInputsMap(newInputsMap)} + key={`input-${index}`} + /> + ); + case 'select': + return ( + + setInputsMap(newInputsMap, editStatus) + } + key={`input-${index}`} + /> + ); + default: + return ( + setInputsMap(newInputsMap)} + key={`input-${index}`} + /> + ); + } } })} diff --git a/client/src/components/InputsModal/InputOAuthCredentials.tsx b/client/src/components/InputsModal/InputOAuthCredentials.tsx index c3399df1d..185238794 100644 --- a/client/src/components/InputsModal/InputOAuthCredentials.tsx +++ b/client/src/components/InputsModal/InputOAuthCredentials.tsx @@ -1,5 +1,4 @@ import React, { FC } from 'react'; -import { ReactJSXElement } from '@emotion/react/types/jsx-namespace'; import { Card, CardContent, @@ -23,15 +22,6 @@ export interface InputOAuthCredentialsProps { setInputsMap: (map: Map, edited?: boolean) => void; } -export interface InputOAuthField { - name: string; - label?: string | ReactJSXElement; - description?: string; // currently empty - required?: boolean; // default behavior should be false - hide?: boolean; // default behavior should be false - locked?: boolean; // default behavior should be false -} - const InputOAuthCredentials: FC = ({ requirement, index, @@ -56,55 +46,53 @@ const InputOAuthCredentials: FC = ({ const showRefreshDetails = !!oAuthCredentials.refresh_token; - const oAuthFields: InputOAuthField[] = [ + const oAuthFields: TestInput[] = [ { name: 'access_token', - label: 'Access Token', - required: !requirement.optional, + title: 'Access Token', + optional: requirement.optional, }, { name: 'refresh_token', - label: 'Refresh Token (will automatically refresh if available)', - required: false, + title: 'Refresh Token (will automatically refresh if available)', + optional: true, }, { name: 'token_url', - label: 'Token Endpoint', + title: 'Token Endpoint', hide: !showRefreshDetails, - required: true, }, { name: 'client_id', - label: 'Client ID', + title: 'Client ID', hide: !showRefreshDetails, - required: true, }, { name: 'client_secret', - label: 'Client Secret', + title: 'Client Secret', hide: !showRefreshDetails, - required: false, + optional: true, }, { name: 'expires_in', - label: 'Expires in (seconds)', + title: 'Expires in (seconds)', hide: !showRefreshDetails, - required: false, + optional: true, }, ]; - const getIsMissingInput = (field: InputOAuthField) => { + const getIsMissingInput = (field: TestInput) => { return ( hasBeenModified[field.name as keyof typeof hasBeenModified] && - field.required && + !field.optional && !oAuthCredentials[field.name as keyof OAuthCredentials] ); }; - const oAuthField = (field: InputOAuthField) => { - const fieldName = field.required - ? `${(field.label || field.name) as string} (required)` - : field.label || field.name; + const oAuthField = (field: TestInput) => { + const fieldName = field.optional + ? field.title || field.name + : `${(field.title || field.name) as string} (required)`; const fieldLabel = ( <> @@ -134,7 +122,7 @@ const InputOAuthCredentials: FC = ({ )} = ({ return ( - + = ({ {requirement.description && ( - + {requirement.description} )} diff --git a/client/src/components/InputsModal/InputRadioGroup.tsx b/client/src/components/InputsModal/InputRadioGroup.tsx index eeb465c3a..1f57e55d6 100644 --- a/client/src/components/InputsModal/InputRadioGroup.tsx +++ b/client/src/components/InputsModal/InputRadioGroup.tsx @@ -26,27 +26,21 @@ const InputRadioGroup: FC = ({ setInputsMap, }) => { const { classes } = useStyles(); - const firstValue = + const firstOptionValue = requirement.options?.list_options && requirement.options?.list_options?.length > 0 ? requirement.options?.list_options[0]?.value : ''; - const [value, setValue] = React.useState( - inputsMap.get(requirement.name) || requirement.default || firstValue - ); - // Set default on mounted + // Set starting value to first option if no value and no default useEffect(() => { - inputsMap.set(requirement.name, value); + const startingValue = + (inputsMap.get(requirement.name) as string) || + (requirement.default as string) || + firstOptionValue; + inputsMap.set(requirement.name, startingValue); setInputsMap(new Map(inputsMap)); }, []); - const handleChange = (event: React.ChangeEvent) => { - const value = event.target.value; - setValue(value); - inputsMap.set(requirement.name, value); - setInputsMap(new Map(inputsMap)); - }; - return ( = ({ fullWidth className={classes.inputField} > - + {requirement.description && ( - + {requirement.description} )} @@ -68,8 +62,13 @@ const InputRadioGroup: FC = ({ row aria-label={`${requirement.name}-radio-buttons-group`} name={`${requirement.name}-radio-buttons-group`} - value={value} - onChange={handleChange} + value={ + inputsMap.get(requirement.name) || (requirement.default as string) || firstOptionValue + } + onChange={(event) => { + inputsMap.set(requirement.name, event.target.value); + setInputsMap(new Map(inputsMap)); + }} > {requirement.options?.list_options?.map((option, i) => ( ; + setInputsMap: (map: Map, edited?: boolean) => void; +} + +const InputSingleCheckbox: FC = ({ + requirement, + index, + inputsMap, + setInputsMap, +}) => { + const { classes } = useStyles(); + const [hasBeenModified, setHasBeenModified] = React.useState(false); + const [value, setValue] = React.useState(false); + + const isMissingInput = + hasBeenModified && !requirement.optional && inputsMap.get(requirement.name) === false; + + const fieldLabel = ( + <> + {' '} + {requirement.optional ? '' : '*'} + + ); + + useEffect(() => { + const inputsValue = inputsMap.get(requirement.name) as string; + let startingValue = false; + if (inputsValue === 'true') { + startingValue = true; + } else if (inputsValue !== 'false' && (requirement.default as string) === 'true') { + startingValue = true; + } + setValue(startingValue); + }, []); + + const handleChange = (event: React.ChangeEvent) => { + const newValue = event.target.checked; + setValue(newValue); + setHasBeenModified(true); + inputsMap.set(requirement.name, newValue.toString()); + setInputsMap(new Map(inputsMap)); + }; + + return ( + + + {requirement.description && ( + + {requirement.description} + + )} + {/* TODO: required means set to true and locked? */} + + { + if (e.currentTarget === e.target) { + setHasBeenModified(true); + } + }} + onChange={handleChange} + /> + } + label={fieldLabel} + key={`checkbox-${requirement.name}`} + /> + + + + ); +}; + +export default InputSingleCheckbox; diff --git a/client/src/components/InputsModal/InputTextField.tsx b/client/src/components/InputsModal/InputTextField.tsx index 8d0b0781d..2a7458c10 100644 --- a/client/src/components/InputsModal/InputTextField.tsx +++ b/client/src/components/InputsModal/InputTextField.tsx @@ -38,7 +38,7 @@ const InputTextField: FC = ({ {requirement.description && ( - + {requirement.description} )} @@ -53,7 +53,7 @@ const InputTextField: FC = ({ multiline={requirement.type === 'textarea'} minRows={requirement.type === 'textarea' ? 4 : 1} maxRows={20} - value={inputsMap.get(requirement.name)} + value={inputsMap.get(requirement.name) || ''} onBlur={(e) => { if (e.currentTarget === e.target) { setHasBeenModified(true); diff --git a/client/src/components/InputsModal/InputsModal.tsx b/client/src/components/InputsModal/InputsModal.tsx index 361156445..0d15ea00e 100644 --- a/client/src/components/InputsModal/InputsModal.tsx +++ b/client/src/components/InputsModal/InputsModal.tsx @@ -19,13 +19,20 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import YAML from 'js-yaml'; import { useSnackbar } from 'notistack'; -import { OAuthCredentials, Runnable, RunnableType, TestInput } from '~/models/testSuiteModels'; +import { + Auth, + OAuthCredentials, + Runnable, + RunnableType, + TestInput, +} from '~/models/testSuiteModels'; +import CopyButton from '~/components/_common/CopyButton'; import CustomTooltip from '~/components/_common/CustomTooltip'; +import DownloadFileButton from '~/components/_common/DownloadFileButton'; +import UploadFileButton from '~/components/_common/UploadFileButton'; import InputFields from '~/components/InputsModal/InputFields'; import useStyles from '~/components/InputsModal/styles'; -import DownloadFileButton from '../_common/DownloadFileButton'; -import UploadFileButton from '../_common/UploadFileButton'; -import CopyButton from '../_common/CopyButton'; +import { AuthType, getAccessFields, getAuthFields } from './AuthSettings'; export interface InputsModalProps { modalVisible: boolean; @@ -86,16 +93,46 @@ const InputsModal: FC = ({ } } + // if input is auth_info, check if required values are filled + let authMissingRequiredInput = false; + if (input.type === 'auth_info') { + try { + if ( + !inputsMap.get(input.name) || + !input.options?.components || + input.options?.components.length < 1 + ) + return false; + const authJson = JSON.parse(inputsMap.get(input.name) as string) as Auth; + const authType = (authJson.auth_type || + input.options.components.find((c) => c.name === 'auth_type')?.default) as AuthType; + + // Determine which fields are required; authValues and components props irrelevant for this + const fields = + input.options?.mode === 'auth' + ? getAuthFields(authType, new Map(), []) + : getAccessFields(authType, new Map(), []); + const requiredFields = fields.filter((field) => !field.optional).map((field) => field.name); + authMissingRequiredInput = requiredFields.some((field) => !authJson[field as keyof Auth]); + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + enqueueSnackbar(`Auth info inputs incorrectly formatted: ${errorMessage}`, { + variant: 'error', + }); + return true; + } + } + // if input has OAuth, check if required values are filled let oAuthMissingRequiredInput = false; if (input.type === 'oauth_credentials') { try { - const oAuthJSON = JSON.parse( + const oAuthJson = JSON.parse( (inputsMap.get(input.name) as string) || '{ "access_token": null }' ) as OAuthCredentials; - const accessTokenIsEmpty = !oAuthJSON.access_token; + const accessTokenIsEmpty = !oAuthJson.access_token; const refreshTokenIsEmpty = - !!oAuthJSON.refresh_token && (!oAuthJSON.token_url || !oAuthJSON.client_id); + !!oAuthJson.refresh_token && (!oAuthJson.token_url || !oAuthJson.client_id); oAuthMissingRequiredInput = (!input.optional && accessTokenIsEmpty) || refreshTokenIsEmpty; } catch (e: unknown) { const errorMessage = e instanceof Error ? e.message : String(e); @@ -106,7 +143,11 @@ const InputsModal: FC = ({ } } - return (!input.optional && !inputsMap.get(input.name)) || oAuthMissingRequiredInput; + return ( + (!input.optional && !inputsMap.get(input.name)) || + oAuthMissingRequiredInput || + authMissingRequiredInput + ); }); const instructions = @@ -170,11 +211,11 @@ const InputsModal: FC = ({ }; const submitClicked = () => { - const inputs_with_values: TestInput[] = []; - inputsMap.forEach((input_value, input_name) => { - inputs_with_values.push({ name: input_name, value: input_value, type: 'text' }); + const inputsWithValues: TestInput[] = []; + inputsMap.forEach((inputValue, inputName) => { + inputsWithValues.push({ name: inputName, value: inputValue, type: 'text' }); }); - createTestRun(runnableType, runnable?.id || '', inputs_with_values); + createTestRun(runnableType, runnable?.id || '', inputsWithValues); closeModal(); }; @@ -190,6 +231,13 @@ const InputsModal: FC = ({ (map.get(requirement.name) as string) || '{ "access_token": "" }' ) as OAuthCredentials, }; + } else if (requirement.type === 'auth_info') { + return { + ...requirement, + default: JSON.parse((requirement.default as string) || '{}') as Auth, + description: parsedDescription, + value: JSON.parse((map.get(requirement.name) as string) || '{}') as Auth, + }; } else if (requirement.type === 'radio') { const firstVal = requirement.options?.list_options && requirement.options?.list_options?.length > 0 @@ -217,9 +265,9 @@ const InputsModal: FC = ({ let parsed: TestInput[]; try { parsed = (inputType === 'JSON' ? JSON.parse(changes) : YAML.load(changes)) as TestInput[]; - // Convert OAuth input values to strings; parsed needs to be an array + // Convert OAuth/Auth input values to strings; parsed needs to be an array parsed.forEach((input) => { - if (input.type === 'oauth_credentials') { + if (input.type === 'oauth_credentials' || input.type === 'auth_info') { input.value = JSON.stringify(input.value); } }); diff --git a/client/src/components/InputsModal/__tests__/Inputs.test.tsx b/client/src/components/InputsModal/__tests__/Inputs.test.tsx index a01b8dbe9..ebe5f380e 100644 --- a/client/src/components/InputsModal/__tests__/Inputs.test.tsx +++ b/client/src/components/InputsModal/__tests__/Inputs.test.tsx @@ -7,6 +7,8 @@ import InputCheckboxGroup from '~/components/InputsModal/InputCheckboxGroup'; import InputRadioGroup from '~/components/InputsModal/InputRadioGroup'; import InputTextField from '~/components/InputsModal/InputTextField'; import InputOAuthCredentials from '~/components/InputsModal/InputOAuthCredentials'; +import InputAuth from '~/components/InputsModal/InputAuth'; +import InputAccess from '~/components/InputsModal/InputAccess'; describe('Input Components', () => { it('renders InputCheckboxGroup', () => { @@ -152,4 +154,70 @@ describe('Input Components', () => { const inputText = screen.getByText('oauthInput'); expect(inputText).toBeVisible(); }); + + it('renders InputAuth', () => { + const authInput = { + name: 'authInput', + type: 'auth_info' as TestInput['type'], + optional: true, + options: { + mode: 'auth', + components: [ + { + default: 'public', + name: 'auth_type', + }, + ], + }, + }; + + render( + + + ()} + setInputsMap={() => {}} + /> + + + ); + + const inputText = screen.getByText('authInput Auth Type (required)'); + expect(inputText).toBeVisible(); + }); + + it('renders InputAccess', () => { + const accessInput = { + name: 'accessInput', + type: 'auth_info' as TestInput['type'], + optional: true, + options: { + mode: 'access', + components: [ + { + default: 'public', + name: 'auth_type', + }, + ], + }, + }; + + render( + + + ()} + setInputsMap={() => {}} + /> + + + ); + + const inputText = screen.getByText('accessInput'); + expect(inputText).toBeVisible(); + }); }); diff --git a/client/src/components/InputsModal/styles.tsx b/client/src/components/InputsModal/styles.tsx index 6a8c11e1d..320228919 100644 --- a/client/src/components/InputsModal/styles.tsx +++ b/client/src/components/InputsModal/styles.tsx @@ -45,7 +45,7 @@ export default makeStyles()((theme: Theme) => ({ maxHeight: '400px', overflow: 'auto !important', }, - oauthCard: { + authCard: { width: '100%', mx: 2, borderColor: theme.palette.common.gray, diff --git a/client/src/components/RequestDetailModal/CodeBlock.tsx b/client/src/components/RequestDetailModal/CodeBlock.tsx index 2cff6cfbc..ea735fabf 100644 --- a/client/src/components/RequestDetailModal/CodeBlock.tsx +++ b/client/src/components/RequestDetailModal/CodeBlock.tsx @@ -5,7 +5,7 @@ import { RequestHeader } from '~/models/testSuiteModels'; import CollapseButton from '~/components/_common/CollapseButton'; import CopyButton from '~/components/_common/CopyButton'; -import { formatBodyIfJSON } from './helpers'; +import { formatBodyIfJson } from './helpers'; import useStyles from './styles'; import lightTheme from '~/styles/theme'; @@ -22,7 +22,7 @@ const CodeBlock: FC = ({ body, headers, title }) => { useEffectOnce(() => { if (body && body.length > 0) { - setJsonBody(formatBodyIfJSON(body, headers)); + setJsonBody(formatBodyIfJson(body, headers)); } }); diff --git a/client/src/components/RequestDetailModal/__mocked_data__/mockData.ts b/client/src/components/RequestDetailModal/__mocked_data__/mockData.ts index 6f76abde0..d2c848f77 100644 --- a/client/src/components/RequestDetailModal/__mocked_data__/mockData.ts +++ b/client/src/components/RequestDetailModal/__mocked_data__/mockData.ts @@ -27,7 +27,7 @@ export const mockedRequest: Request = { verb: 'get', }; -export const codeResponseWithHTML: Request = { +export const codeResponseWithHtml: Request = { direction: 'outgoing', id: 'de793781-5eed-421a-88d3-8029499f4bce', index: 2, @@ -41,7 +41,7 @@ export const codeResponseWithHTML: Request = { response_body: 'html has newlines already', }; -export const codeResponseWithJSON: Request = { +export const codeResponseWithJson: Request = { direction: 'outgoing', id: 'de793781-5eed-421a-88d3-8029499f4bce', index: 3, diff --git a/client/src/components/RequestDetailModal/__tests__/CodeBlock.test.tsx b/client/src/components/RequestDetailModal/__tests__/CodeBlock.test.tsx index 82fc6abb5..84b175356 100644 --- a/client/src/components/RequestDetailModal/__tests__/CodeBlock.test.tsx +++ b/client/src/components/RequestDetailModal/__tests__/CodeBlock.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, screen, getDefaultNormalizer } from '@testing-library/react'; import ThemeProvider from 'components/ThemeProvider'; import CodeBlock from '../CodeBlock'; -import { codeResponseWithHTML, codeResponseWithJSON } from '../__mocked_data__/mockData'; +import { codeResponseWithHtml, codeResponseWithJson } from '../__mocked_data__/mockData'; import { vi } from 'vitest'; @@ -12,8 +12,8 @@ describe('CodeBlock', () => { }); it('displays the code body as given if HTML', () => { - const headers = codeResponseWithHTML.response_headers; - const body = codeResponseWithHTML.response_body; + const headers = codeResponseWithHtml.response_headers; + const body = codeResponseWithHtml.response_body; render( @@ -27,8 +27,8 @@ describe('CodeBlock', () => { }); it('displays pretty printed JSON if given JSON', () => { - const headers = codeResponseWithJSON.response_headers; - const body = codeResponseWithJSON.response_body; + const headers = codeResponseWithJson.response_headers; + const body = codeResponseWithJson.response_body; render( diff --git a/client/src/components/RequestDetailModal/helpers/index.ts b/client/src/components/RequestDetailModal/helpers/index.ts index ba05d6483..5342d603f 100644 --- a/client/src/components/RequestDetailModal/helpers/index.ts +++ b/client/src/components/RequestDetailModal/helpers/index.ts @@ -1,7 +1,7 @@ import { enqueueSnackbar } from 'notistack'; import { RequestHeader } from '~/models/testSuiteModels'; -export const formatBodyIfJSON = ( +export const formatBodyIfJson = ( code: string, headers: RequestHeader[] | null | undefined ): string => { @@ -12,23 +12,23 @@ export const formatBodyIfJSON = ( const contentTypeHeader = headers.find((h) => h.name === 'content-type'); - let isJSON = false; + let isJson = false; if (contentTypeHeader) { const contentType = contentTypeHeader.value; if (contentType.includes('application/fhir+json') || contentType.includes('application/json')) { - isJSON = true; + isJson = true; } } - if (isJSON) { - return formatJSON(code); + if (isJson) { + return formatJson(code); } else { // it is probably HTML so don't JSON format it return code; } }; -const formatJSON = (json: string): string => { +const formatJson = (json: string): string => { try { return JSON.stringify(JSON.parse(json), null, 2); } catch (error) { diff --git a/client/src/components/TestSuite/TestSuiteDetails/TestListItem/TestListItem.tsx b/client/src/components/TestSuite/TestSuiteDetails/TestListItem/TestListItem.tsx index 4211334e5..99e51db5e 100644 --- a/client/src/components/TestSuite/TestSuiteDetails/TestListItem/TestListItem.tsx +++ b/client/src/components/TestSuite/TestSuiteDetails/TestListItem/TestListItem.tsx @@ -166,7 +166,7 @@ const TestListItem: FC = ({ ); const testRunButton = view === 'run' && runTests && ( - e.stopPropagation()}> + e.stopPropagation()} display="flex" justifyContent="center"> ); diff --git a/client/src/components/TestSuite/TestSuiteDetails/TestListItem/__tests__/RequestList.test.tsx b/client/src/components/TestSuite/TestSuiteDetails/TestListItem/__tests__/RequestList.test.tsx index d7ca0a789..10aa4eb48 100644 --- a/client/src/components/TestSuite/TestSuiteDetails/TestListItem/__tests__/RequestList.test.tsx +++ b/client/src/components/TestSuite/TestSuiteDetails/TestListItem/__tests__/RequestList.test.tsx @@ -9,12 +9,12 @@ import ThemeProvider from 'components/ThemeProvider'; import RequestList from '../RequestList'; import { mockedRequest, - codeResponseWithHTML, + codeResponseWithHtml, } from '~/components/RequestDetailModal/__mocked_data__/mockData'; describe('The RequestsList component', () => { test('it orders requests based on their index', async () => { - const requests = [codeResponseWithHTML, mockedRequest]; + const requests = [codeResponseWithHtml, mockedRequest]; await act(() => render( @@ -30,11 +30,11 @@ describe('The RequestsList component', () => { expect(renderedRequests.length).toEqual(requests.length); expect(renderedRequests[0]).toHaveTextContent(mockedRequest.url); - expect(renderedRequests[1]).toHaveTextContent(codeResponseWithHTML.url); + expect(renderedRequests[1]).toHaveTextContent(codeResponseWithHtml.url); }); test('copies url when button is clicked', async () => { - const requests = [codeResponseWithHTML, mockedRequest]; + const requests = [codeResponseWithHtml, mockedRequest]; // Keep a copy to restore original clipboard const originalClipboard = navigator.clipboard; const mockedWriteText = vi.fn(); @@ -70,7 +70,7 @@ describe('The RequestsList component', () => { }); test('shows details when button is clicked', async () => { - const requests = [codeResponseWithHTML, mockedRequest]; + const requests = [codeResponseWithHtml, mockedRequest]; await act(() => render( diff --git a/client/src/models/testSuiteModels.ts b/client/src/models/testSuiteModels.ts index 275aeb91f..12cdfd943 100644 --- a/client/src/models/testSuiteModels.ts +++ b/client/src/models/testSuiteModels.ts @@ -1,5 +1,27 @@ +import { ReactJSXElement } from '@emotion/react/types/jsx-namespace'; import { Option } from './selectionModels'; +export interface Auth { + auth_type?: string; + use_discovery?: boolean; + token_url?: string; + auth_url?: string; + requested_scopes?: string; + client_id?: string; + client_secret?: string; + redirect_url?: string; + pkce_support?: string; + pkce_code_challenge_method?: string; + auth_request_method?: string; + encryption_algorithm?: string; + kid?: string; + jwks?: string; + access_token?: string; + refresh_token?: string; + issue_time?: string; + expires_in?: string; +} + export interface CheckboxValues { [key: string]: boolean; } @@ -14,6 +36,10 @@ export interface InputOption { value: string; } +export interface InputValues { + [key: string]: unknown; +} + export type Message = { message: string; type: 'error' | 'warning' | 'info'; @@ -87,15 +113,18 @@ export interface SuiteOptionChoice { export interface TestInput { name: string; - title?: string; + title?: string | ReactJSXElement; value?: unknown; - type?: 'auth_info' | 'oauth_credentials' | 'checkbox' | 'radio' | 'text' | 'textarea'; + type?: 'auth_info' | 'oauth_credentials' | 'checkbox' | 'radio' | 'select' | 'text' | 'textarea'; description?: string; default?: string | string[]; optional?: boolean; locked?: boolean; + hide?: boolean; options?: { + components?: TestInput[]; list_options?: InputOption[]; + mode?: string; }; } diff --git a/spec/fixtures/auth_info_constants.rb b/spec/fixtures/auth_info_constants.rb index a65ba96d4..c4527e1f2 100644 --- a/spec/fixtures/auth_info_constants.rb +++ b/spec/fixtures/auth_info_constants.rb @@ -37,7 +37,7 @@ def symmetric_confidential_access_default requested_scopes: REQUESTED_SCOPES, pkce_support: 'enabled', pkce_code_challenge_method: 'S256', - auth_request_method: 'POST', + auth_request_method: 'post', use_discovery: 'false' }.merge(token_info) end @@ -49,7 +49,7 @@ def asymmetric_confidential_access_default client_id: 'SAMPLE_CONFIDENTIAL_CLIENT_ID', requested_scopes: REQUESTED_SCOPES, pkce_support: 'disabled', - auth_request_method: 'POST', + auth_request_method: 'post', encryption_algorithm: ENCRYPTION_ALGORITHM, jwks: JWKS, kid: KID