Skip to content

Commit

Permalink
chore: validate on submit for server action (#4970)
Browse files Browse the repository at this point in the history
* add validate on submit

---------

Co-authored-by: Dave Samojlenko <dave.samojlenko@cds-snc.ca>
Co-authored-by: Clément Janin <clement.janin@cds-snc.ca>
  • Loading branch information
3 people authored Jan 15, 2025
1 parent 4471fac commit b5ee731
Show file tree
Hide file tree
Showing 5 changed files with 135 additions and 17 deletions.
33 changes: 25 additions & 8 deletions app/(gcforms)/[locale]/(form filler)/id/[...props]/actions.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,43 @@
"use server";

import { PublicFormRecord, Responses, SubmissionRequestBody } from "@lib/types";
import { Responses, SubmissionRequestBody } from "@lib/types";
import { buildFormDataObject } from "./lib/buildFormDataObject";
import { parseRequestData } from "./lib/parseRequestData";
import { processFormData } from "./lib/processFormData";
import { MissingFormDataError, MissingFormIdError } from "./lib/exceptions";
import { MissingFormDataError } from "./lib/exceptions";
import { logMessage } from "@lib/logger";
import { getPublicTemplateByID } from "@lib/templates";
import { validateResponses } from "@lib/validation/validation";

// Public facing functions - they can be used by anyone who finds the associated server action identifer

export async function submitForm(
values: Responses,
language: string,
formRecord: PublicFormRecord
formId: string
): Promise<{ id: string; error?: Error }> {
try {
const formDataObject = buildFormDataObject(formRecord, values);
const formRecord = await getPublicTemplateByID(formId);

if (!formDataObject.formID) {
throw new MissingFormIdError("No form ID submitted with request");
if (!formRecord) {
throw new Error(`Could not find any form associated to identifier ${formId}`);
}

const validateResponsesResult = await validateResponses(values, formRecord, language);

if (Object.keys(validateResponsesResult).length !== 0) {
logMessage.warn(
`[server-action][submitForm] Detected invalid response(s) in submission on form ${formId}. Errors: ${JSON.stringify(
validateResponsesResult
)}`
);

// Turn this on after we've monitored the logs for a while
// throw new MissingFormDataError("Form data validation failed");
}

const formDataObject = buildFormDataObject(formRecord, values);

if (Object.entries(formDataObject).length <= 2) {
throw new MissingFormDataError("No form data submitted with request");
}
Expand All @@ -32,9 +49,9 @@ export async function submitForm(
return { id: formRecord.id };
} catch (e) {
logMessage.error(
`Could not submit response for form ${formRecord.id}. Received error: ${(e as Error).message}`
`Could not submit response for form ${formId}. Received error: ${(e as Error).message}`
);

return { id: formRecord.id, error: { name: (e as Error).name, message: (e as Error).message } };
return { id: formId, error: { name: (e as Error).name, message: (e as Error).message } };
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
export class MissingFormIdError extends Error {
constructor(message?: string) {
super(message ?? "MissingFormIdError");
Object.setPrototypeOf(this, MissingFormIdError.prototype);
}
}
export class MissingFormDataError extends Error {
constructor(message?: string) {
super(message ?? "MissingFormDataError");
Expand Down
2 changes: 1 addition & 1 deletion components/clientComponents/forms/Form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ export const Form = withFormik<FormProps, Responses>({
const result = await submitForm(
formValues,
formikBag.props.language,
formikBag.props.formRecord
formikBag.props.formRecord.id
);

// Failed to find Server Action (likely due to newer deployment)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export const FormattedDate = (props: FormattedDateProps): React.ReactElement =>
{meta.error && <ErrorMessage id={"errorMessage" + id}>{meta.error}</ErrorMessage>}

<div className="inline-flex gap-2">
<input type="hidden" {...field} />
<input type="hidden" {...field} value={field.value || ""} />
{dateParts.map((part) => {
// Not currently an option, for future use
return part === DatePart.MM && monthSelector === "select" ? (
Expand Down
109 changes: 108 additions & 1 deletion lib/validation/validation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ import { FormikProps } from "formik";
import { TFunction } from "i18next";
import { ErrorListItem } from "@clientComponents/forms";
import { ErrorListMessage } from "@clientComponents/forms/ErrorListItem/ErrorListMessage";
import { isServer } from "../tsUtils";
import { hasOwnProperty, isServer } from "../tsUtils";
import uuidArraySchema from "@lib/middleware/schemas/uuid-array.schema.json";
import formNameArraySchema from "@lib/middleware/schemas/submission-name-array.schema.json";
import { matchRule, FormValues, GroupsType } from "@lib/formContext";
import { inGroup } from "@lib/formContext";
import { isFileExtensionValid, isAllFilesSizeValid } from "./fileValidationClientSide";
import { DateObject } from "@clientComponents/forms/FormattedDate/types";
import { isValidDate } from "@clientComponents/forms/FormattedDate/utils";
import { serverTranslation } from "@i18n";

/**
* getRegexByType [private] defines a mapping between the types of fields that need to be validated
Expand Down Expand Up @@ -222,6 +223,112 @@ const isFieldResponseValid = (
return null;
};

const valueMatchesType = (value: unknown, type: string, formElement: FormElement) => {
switch (type) {
case FormElementTypes.formattedDate:
if (value && isValidDate(JSON.parse(value as string) as DateObject)) {
return true;
}
return false;
case FormElementTypes.checkbox: {
if (Array.isArray(value)) {
return true;
}
return false;
}
case FormElementTypes.fileInput: {
const fileInputResponse = value as FileInputResponse;
if (
fileInputResponse &&
hasOwnProperty(fileInputResponse, "name") &&
hasOwnProperty(fileInputResponse, "size") &&
hasOwnProperty(fileInputResponse, "based64EncodedFile")
) {
return true;
}
return false;
}
case FormElementTypes.dynamicRow: {
if (!Array.isArray(value)) {
return false;
}

let valid = true;

for (const row of value as Array<Responses>) {
for (const [responseKey, responseValue] of Object.entries(row)) {
if (
formElement.properties.subElements &&
formElement.properties.subElements[parseInt(responseKey)]
) {
const subElement = formElement.properties.subElements[parseInt(responseKey)];
const result = valueMatchesType(responseValue, subElement.type, subElement);

if (!result) {
valid = false;
break;
}
}
}
}

return valid;
}
default:
if (typeof value === "string") {
return true;
}
}

return false;
};

/**
* Server-side validation the form responses
*/
export const validateResponses = async (
values: Responses,
formRecord: PublicFormRecord,
language: string
) => {
const errors: Responses = {};
const { t } = await serverTranslation(["common"], { lang: language });

for (const item in values) {
const formElement = formRecord.form.elements.find((element) => element.id == parseInt(item));

if (!formElement) {
errors[item] = "response-to-non-existing-question";
continue;
}

// Check if the incoming value matches the type of the form element
const result = valueMatchesType(values[item], formElement.type, formElement);

if (!result) {
errors[item] = "invalid-response-data-type";
}

// Check for required fields
if (formElement.properties.validation) {
const result = isFieldResponseValid(
values[item],
values,
formElement.type,
formElement,
formElement.properties.validation,
t
);

if (result) {
errors[item] = result;
}
}
}

return errors;
};

/**
* validateOnSubmit is called during Formik's submission event to validate the fields
* @param values
Expand Down

0 comments on commit b5ee731

Please sign in to comment.