From 4e059c0de6d910dfcefda428b71ac75074bc1d94 Mon Sep 17 00:00:00 2001 From: Angela Cooney Date: Wed, 18 Dec 2024 11:11:26 -0500 Subject: [PATCH] CMDCT-4190: adding validation and schemaMap files with tests --- services/app-api/package.json | 3 +- services/app-api/types/types.ts | 4 + services/app-api/utils/schemaMap.ts | 255 ++++++++++++++++++ .../app-api/utils/tests/schemaMap.test.ts | 172 ++++++++++++ .../app-api/utils/tests/validation.test.ts | 101 +++++++ services/app-api/utils/validation.ts | 81 ++++++ services/app-api/yarn.lock | 30 +++ 7 files changed, 645 insertions(+), 1 deletion(-) create mode 100644 services/app-api/utils/schemaMap.ts create mode 100644 services/app-api/utils/tests/schemaMap.test.ts create mode 100644 services/app-api/utils/tests/validation.test.ts create mode 100644 services/app-api/utils/validation.ts diff --git a/services/app-api/package.json b/services/app-api/package.json index e6e464e4..7072f0cd 100644 --- a/services/app-api/package.json +++ b/services/app-api/package.json @@ -32,7 +32,8 @@ "jsdom": "20.0.0", "jwt-decode": "3.1.2", "ksuid": "^3.0.0", - "util": "^0.12.5" + "util": "^0.12.5", + "yup": "^1.6.1" }, "jest": { "verbose": true, diff --git a/services/app-api/types/types.ts b/services/app-api/types/types.ts index e8827e62..712a7bef 100644 --- a/services/app-api/types/types.ts +++ b/services/app-api/types/types.ts @@ -75,3 +75,7 @@ export interface AuthenticatedRequest { export type HandlerLambda = ( request: AuthenticatedRequest ) => Promise; + +export interface AnyObject { + [key: string]: any; +} diff --git a/services/app-api/utils/schemaMap.ts b/services/app-api/utils/schemaMap.ts new file mode 100644 index 00000000..1476c538 --- /dev/null +++ b/services/app-api/utils/schemaMap.ts @@ -0,0 +1,255 @@ +import { + array, + boolean, + mixed, + number as numberSchema, + object, + string, + StringSchema, +} from "yup"; + +const error = { + REQUIRED_GENERIC: "A response is required", + INVALID_EMAIL: "Response must be a valid email address", + INVALID_URL: "Response must be a valid hyperlink/URL", + INVALID_DATE: "Response must be a valid date", + INVALID_END_DATE: "End date can't be before start date", + NUMBER_LESS_THAN_ONE: "Response must be greater than or equal to one", + NUMBER_LESS_THAN_ZERO: "Response must be greater than or equal to zero", + INVALID_NUMBER: "Response must be a valid number", + INVALID_NUMBER_OR_NA: 'Response must be a valid number or "N/A"', + INVALID_RATIO: "Response must be a valid ratio", +}; + +const isWhitespaceString = (value?: string) => value?.trim().length === 0; + +// CHECKBOX +export const checkbox = () => + array() + .min(0) + .of(object({ key: text(), value: text() })); +export const checkboxOptional = () => checkbox(); +export const checkboxSingle = () => boolean(); + +// TEXT +export const text = (): StringSchema => string(); +export const textOptional = () => text(); + +// DATE +export const dateFormatRegex = + /^((0[1-9]|1[0-2])\/(0[1-9]|1\d|2\d|3[01])\/(19|20)\d{2})|((0[1-9]|1[0-2])(0[1-9]|1\d|2\d|3[01])(19|20)\d{2})|\s$/; + +export const date = () => + string().test({ + message: error.INVALID_DATE, + test: (value) => !!value?.match(dateFormatRegex) || value?.trim() === "", + }); + +export const dateOptional = () => date(); +export const endDate = (startDateField: string) => + date().test( + "is-after-start-date", + error.INVALID_END_DATE, + (endDateString, context) => { + return isEndDateAfterStartDate( + context.parent[startDateField], + endDateString as string + ); + } + ); + +export const isEndDateAfterStartDate = ( + startDateString: string, + endDateString: string +) => { + const startDate = new Date(startDateString); + const endDate = new Date(endDateString!); + return endDate >= startDate; +}; + +// DROPDOWN +export const dropdown = () => object({ label: text(), value: text() }); + +// DYNAMIC +export const dynamic = () => array().min(0).of(mixed()); +export const dynamicOptional = () => array().notRequired().nullable(); + +// EMAIL +export const email = () => text().email(error.INVALID_EMAIL); +export const emailOptional = () => email(); + +// NUMBER +export const validNAValues = [ + "N/A", + "NA", + "na", + "n/a", + "N/a", + "Data not available", +]; + +/** This regex must be at least as permissive as the one in ui-src */ +const validNumberRegex = /^\.$|[0-9]/; + +export const number = () => + string().test({ + message: error.INVALID_NUMBER_OR_NA, + test: (value) => { + if (value) { + const isValidStringValue = validNAValues.includes(value); + const isValidNumberValue = validNumberRegex.test(value); + return isValidStringValue || isValidNumberValue; + } else return true; + }, + }); + +export const numberNotLessThanOne = () => + string() + .required(error.REQUIRED_GENERIC) + .test({ + test: (value) => validNumberRegex.test(value!), + message: error.INVALID_NUMBER, + }) + .test({ + test: (value) => parseInt(value!) >= 1, + message: error.NUMBER_LESS_THAN_ONE, + }); + +export const numberNotLessThanZero = () => + string() + .required(error.REQUIRED_GENERIC) + .test({ + test: (value) => validNumberRegex.test(value!), + message: error.INVALID_NUMBER, + }) + .test({ + test: (value) => parseFloat(value!) >= 0, + message: error.NUMBER_LESS_THAN_ZERO, + }); + +export const numberOptional = () => number(); + +const validNumberSchema = () => + string().test({ + message: error.INVALID_NUMBER, + test: (value) => { + return typeof value !== "undefined" + ? validNumberRegex.test(value) + : false; + }, + }); + +export const validNumber = () => + validNumberSchema() + .required(error.REQUIRED_GENERIC) + .test({ + test: (value) => !isWhitespaceString(value), + message: error.REQUIRED_GENERIC, + }); + +export const validNumberOptional = () => + validNumberSchema().notRequired().nullable(); + +// OBJECT ARRAY +export const objectArray = () => array().of(mixed()); + +// RADIO +export const radio = () => + array() + .min(0) + .of(object({ key: text(), value: text() })); +export const radioOptional = () => radio(); + +// Number - Ratio +const valueCleaningNumberSchema = (value: string, charsToReplace: RegExp) => { + return numberSchema().transform((_value) => { + return Number(value.replace(charsToReplace, "")); + }); +}; + +export const ratio = () => + mixed().test({ + message: error.INVALID_RATIO, + test: (val: any) => { + // allow if blank + if (val === "" || !val) return true; + + const replaceCharsRegex = /[,.:]/g; + const ratio = val.split(":"); + + // Double check and make sure that a ratio contains numbers on both sides + if ( + ratio.length != 2 || + ratio[0].trim().length == 0 || + ratio[1].trim().length == 0 + ) { + return false; + } + + // Check if the left side of the ratio is a valid number + const firstTest = valueCleaningNumberSchema( + ratio[0], + replaceCharsRegex + ).isValidSync(val); + + // Check if the right side of the ratio is a valid number + const secondTest = valueCleaningNumberSchema( + ratio[1], + replaceCharsRegex + ).isValidSync(val); + + // If both sides are valid numbers, return true! + return firstTest && secondTest; + }, + }); + +// URL +export const url = () => text().url(error.INVALID_URL); +export const urlOptional = () => url(); + +// NESTED +export const nested = ( + fieldSchema: Function, + parentFieldName: string, + parentOptionId: string +) => { + const fieldTypeMap = { + array: array(), + string: string(), + date: date(), + object: object(), + }; + const fieldType: keyof typeof fieldTypeMap = fieldSchema().type; + const baseSchema: any = fieldTypeMap[fieldType]; + return baseSchema.when(parentFieldName, { + is: (value: any[]) => + // look for parentOptionId in checked Choices + value?.find((option: any) => option.key === parentOptionId), + then: () => fieldSchema(), // returns standard field schema (required) + otherwise: () => baseSchema, // returns not-required Yup base schema + }); +}; + +export const schemaMap: any = { + checkbox: checkbox(), + checkboxOptional: checkboxOptional(), + checkboxSingle: checkboxSingle(), + date: date(), + dateOptional: dateOptional(), + dropdown: dropdown(), + dynamic: dynamic(), + dynamicOptional: dynamicOptional(), + email: email(), + emailOptional: emailOptional(), + number: number(), + numberNotLessThanOne: numberNotLessThanOne(), + numberOptional: numberOptional(), + objectArray: objectArray(), + radio: radio(), + radioOptional: radioOptional(), + ratio: ratio(), + text: text(), + textOptional: textOptional(), + url: url(), + urlOptional: urlOptional(), +}; diff --git a/services/app-api/utils/tests/schemaMap.test.ts b/services/app-api/utils/tests/schemaMap.test.ts new file mode 100644 index 00000000..176ced9f --- /dev/null +++ b/services/app-api/utils/tests/schemaMap.test.ts @@ -0,0 +1,172 @@ +import { AnyObject, MixedSchema } from "yup"; +import { + number, + ratio, + date, + isEndDateAfterStartDate, + nested, + validNumber, + numberNotLessThanOne, + numberNotLessThanZero, + validNAValues, +} from "../schemaMap"; +import {} from "../validation"; + +describe("Schemas", () => { + const goodNumberTestCases = [ + "", + "123", + "123.00", + "123..00", + "1,230", + "1,2,30", + "1230", + "123450123..,,,.123123123123", + ...validNAValues, + ]; + const badNumberTestCases = ["abc", "N", "!@#!@%"]; + + const zeroTest = ["0", "0.0"]; + + const goodPositiveNumberTestCases = [ + "123", + "123.00", + "123..00", + "1,230", + "1,2,30", + "1230", + "123450123..,,,.123123123123", + ]; + + const negativeNumberTestCases = [ + "-123", + "-123.00", + "-123..00", + "-1,230", + "-1,2,30", + "-1230", + "-123450123..,,,.123123123123", + ]; + + const goodRatioTestCases = [ + "1:1", + "123:123", + "1,234:1.12", + "0:1", + "1:10,000", + ]; + + const badRatioTestCases = [ + ":", + ":1", + "1:", + "1", + "1234", + "abc", + "N/A", + "abc:abc", + ":abc", + "abc:", + "%@#$!ASDF", + ]; + + const goodDateTestCases = ["01/01/1990", "12/31/2020", "01012000"]; + const badDateTestCases = ["01-01-1990", "13/13/1990", "12/32/1990"]; + + const goodValidNumberTestCases = [1, "1", "100000", "1,000,000"]; + const badValidNumberTestCases = ["N/A", "number", "foo"]; + + // nested + const fieldValidationObject = { + type: "text", + nested: true, + parentFieldName: "mock-parent-field-name", + }; + const validationSchema = { + type: "string", + }; + + const testSchema = ( + schemaToUse: MixedSchema, + testCases: Array, + expectedReturn: boolean + ) => { + for (let testCase of testCases) { + let test = schemaToUse.isValidSync(testCase); + + expect(test).toEqual(expectedReturn); + } + }; + + const testValidNumber = ( + schemaToUse: MixedSchema, + testCases: Array, + expectedReturn: boolean + ) => { + for (let testCase of testCases) { + let test = schemaToUse.isValidSync(testCase); + expect(test).toEqual(expectedReturn); + } + }; + + test("Evaluate Number Schema using number scheme", () => { + testSchema(number(), goodNumberTestCases, true); + testSchema(number(), badNumberTestCases, false); + }); + + // testing numberNotLessThanOne scheme + test("Evaluate Number Schema using numberNotLessThanOne scheme", () => { + testSchema(numberNotLessThanOne(), goodPositiveNumberTestCases, true); + testSchema(numberNotLessThanOne(), badNumberTestCases, false); + }); + + test("Test zero values using numberNotLessThanOne scheme", () => { + testSchema(numberNotLessThanOne(), zeroTest, false); + }); + + test("Test negative values using numberNotLessThanOne scheme", () => { + testSchema(numberNotLessThanOne(), negativeNumberTestCases, false); + }); + + // testing numberNotLessThanZero scheme + test("Evaluate Number Schema using numberNotLessThanZero scheme", () => { + testSchema(numberNotLessThanZero(), goodPositiveNumberTestCases, true); + testSchema(numberNotLessThanZero(), badNumberTestCases, false); + }); + + test("Test zero values using numberNotLessThanZero scheme", () => { + testSchema(numberNotLessThanZero(), zeroTest, true); + }); + + test("Test negative values using numberNotLessThanZero scheme", () => { + testSchema(numberNotLessThanZero(), negativeNumberTestCases, false); + }); + + test("Evaluate Number Schema using ratio scheme", () => { + testSchema(ratio(), goodRatioTestCases, true); + testSchema(ratio(), badRatioTestCases, false); + }); + + test("Evaluate Date Schema using date scheme", () => { + testSchema(date(), goodDateTestCases, true); + testSchema(date(), badDateTestCases, false); + }); + + test("Evaluate End Date Schema using date scheme", () => { + expect(isEndDateAfterStartDate("01/01/1989", "01/01/1990")).toBeTruthy(); + expect(isEndDateAfterStartDate("01/01/1990", "01/01/1989")).toBeFalsy(); + }); + + test("Test Nested Schema using nested scheme", () => { + testSchema( + nested(() => validationSchema, fieldValidationObject.parentFieldName, ""), + ["string"], + true + ); + }); + + test("Test validNumber schema", () => { + testValidNumber(validNumber() as any, goodValidNumberTestCases, true); + testValidNumber(validNumber() as any, badValidNumberTestCases, false); + }); +}); diff --git a/services/app-api/utils/tests/validation.test.ts b/services/app-api/utils/tests/validation.test.ts new file mode 100644 index 00000000..29a40bb0 --- /dev/null +++ b/services/app-api/utils/tests/validation.test.ts @@ -0,0 +1,101 @@ +import { + filterValidationSchema, + mapValidationTypesToSchema, +} from "../validation"; +import * as schema from "../schemaMap"; + +const mockStandardValidationType = { + key: "text", +}; + +const mockNestedValidationType = { + key: { + type: "text", + nested: true, + parentFieldName: "mock-parent-field-name", + parentOptionId: "mock-parent-option-name", + }, +}; + +const mockDependentValidationType = { + key: { + type: "endDate", + dependentFieldName: "mock-dependent-field-name", + }, +}; + +const mockNestedDependentValidationType = { + key: { + type: "endDate", + dependentFieldName: "mock-dependent-field-name", + nested: true, + parentFieldName: "mock-parent-field-name", + parentOptionId: "mock-parent-option-name", + }, +}; + +describe("Test mapValidationTypesToSchema", () => { + it("Returns standard validation schema if passed standard validation type", () => { + const result = mapValidationTypesToSchema(mockStandardValidationType); + expect(JSON.stringify(result)).toEqual( + JSON.stringify({ key: schema.text() }) + ); + }); + + it("Returns nested validation schema if passed nested validation type", () => { + const result = mapValidationTypesToSchema(mockNestedValidationType); + expect(JSON.stringify(result)).toEqual( + JSON.stringify({ + key: schema.nested( + () => schema.text(), + "mock-parent-field-name", + "mock-parent-option-name" + ), + }) + ); + }); + + it("Returns dependent validation schema if passed dependent validation type", () => { + const result = mapValidationTypesToSchema(mockDependentValidationType); + expect(JSON.stringify(result)).toEqual( + JSON.stringify({ + key: schema.endDate("mock-dependent-field-name"), + }) + ); + }); + + it("Returns nested dependent validation schema if passed nested dependent validation type", () => { + const result = mapValidationTypesToSchema( + mockNestedDependentValidationType + ); + expect(JSON.stringify(result)).toEqual( + JSON.stringify({ + key: schema.nested( + () => schema.endDate("mock-dependent-field-name"), + "mock-parent-field-name", + "mock-parent-option-name" + ), + }) + ); + }); +}); + +const mockValidationObject = { + "mock-field-1": "text", + "mock-field-2": "email", + "mock-field-3": "number", +}; +const mockDataObject = { + "mock-field-1": undefined, + "mock-field-3": undefined, +}; + +describe("Test filterValidationSchema", () => { + it("Filters out validation objects for which there is no field data being passed", () => { + const result = filterValidationSchema(mockValidationObject, mockDataObject); + expect(result).toEqual({ + "mock-field-1": "text", + "mock-field-3": "number", + }); + }); +}); diff --git a/services/app-api/utils/validation.ts b/services/app-api/utils/validation.ts new file mode 100644 index 00000000..db29c433 --- /dev/null +++ b/services/app-api/utils/validation.ts @@ -0,0 +1,81 @@ +import { AnyObject } from "../types/types"; +import { error } from "./constants"; +import { endDate, nested, schemaMap } from "./schemaMap"; + +// compare payload data against validation schema +export const validateData = async ( + validationSchema: AnyObject, + data: AnyObject, + options?: AnyObject +) => { + try { + // returns valid data to be passed through API + return await validationSchema.validate(data, { + stripUnknown: true, + ...options, + }); + } catch { + throw new Error(error.INVALID_DATA); + } +}; + +// filter field validation to just what's needed for the passed fields +export const filterValidationSchema = ( + validationObject: AnyObject, + data: AnyObject +): AnyObject => { + const validationEntries = Object.entries(validationObject); + const dataKeys = Object.keys(data); + const filteredEntries = validationEntries.filter( + (entry: [string, string | AnyObject]) => { + const [entryKey] = entry; + return dataKeys.includes(entryKey); + } + ); + return Object.fromEntries(filteredEntries); +}; + +// map field validation types to validation schema +export const mapValidationTypesToSchema = (fieldValidationTypes: AnyObject) => { + let validationSchema: AnyObject = {}; + // for each field to be validated, + Object.entries(fieldValidationTypes).forEach( + (fieldValidationType: [string, string | AnyObject]) => { + const [key, fieldValidation] = fieldValidationType; + // if standard validation type, set corresponding schema from map + if (typeof fieldValidation === "string") { + const correspondingSchema = schemaMap[fieldValidation]; + if (correspondingSchema) { + validationSchema[key] = correspondingSchema; + } + } + // else if nested validation type, make and set nested schema + else if (fieldValidation.nested) { + validationSchema[key] = makeNestedFieldSchema(fieldValidation); + // else if not nested, make and set other dependent field types + } else if (fieldValidation.type === "endDate") { + validationSchema[key] = makeEndDateFieldSchema(fieldValidation); + } + } + ); + return validationSchema; +}; + +export const makeEndDateFieldSchema = (fieldValidationObject: AnyObject) => { + const { dependentFieldName } = fieldValidationObject; + return endDate(dependentFieldName); +}; + +export const makeNestedFieldSchema = (fieldValidationObject: AnyObject) => { + const { type, parentFieldName, parentOptionId } = fieldValidationObject; + if (fieldValidationObject.type === "endDate") { + return nested( + () => makeEndDateFieldSchema(fieldValidationObject), + parentFieldName, + parentOptionId + ); + } else { + const fieldValidationSchema = schemaMap[type]; + return nested(() => fieldValidationSchema, parentFieldName, parentOptionId); + } +}; diff --git a/services/app-api/yarn.lock b/services/app-api/yarn.lock index a061c227..7c58c1a1 100644 --- a/services/app-api/yarn.lock +++ b/services/app-api/yarn.lock @@ -3536,6 +3536,11 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" +property-expr@^2.0.5: + version "2.0.6" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8" + integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA== + psl@^1.1.33: version "1.9.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" @@ -3817,6 +3822,11 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +tiny-case@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03" + integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q== + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -3834,6 +3844,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg== + tough-cookie@^4.0.0: version "4.1.4" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36" @@ -3905,6 +3920,11 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== +type-fest@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" + integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== + undici-types@~6.19.2: version "6.19.8" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" @@ -4104,3 +4124,13 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +yup@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/yup/-/yup-1.6.1.tgz#8defcff9daaf9feac178029c0e13b616563ada4b" + integrity sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA== + dependencies: + property-expr "^2.0.5" + tiny-case "^1.0.3" + toposort "^2.0.2" + type-fest "^2.19.0"