From 914184c56561b2ec736c4939a4255a50bfdcf0ca Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 14:21:58 +0800 Subject: [PATCH 01/22] [MOL-22453][SX] CI: harden GitHub Actions workflow against injection via env vars --- .github/workflows/trigger-gitlab-pipeline.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/trigger-gitlab-pipeline.yml b/.github/workflows/trigger-gitlab-pipeline.yml index c3be0f3c6..64c151dad 100644 --- a/.github/workflows/trigger-gitlab-pipeline.yml +++ b/.github/workflows/trigger-gitlab-pipeline.yml @@ -16,10 +16,15 @@ jobs: steps: - name: Print Configs + env: + HEAD_REF: ${{ github.head_ref }} + REF_NAME: ${{ github.ref_name }} + PR_TITLE: ${{ github.event.pull_request.title }} + HEAD_COMMIT_MSG: ${{ github.event.head_commit.message }} run: | - [[ $GITHUB_EVENT_NAME = "pull_request" ]] && BRANCH_NAME="${{ github.head_ref }}" || BRANCH_NAME="${{ github.ref_name }}" + [[ $GITHUB_EVENT_NAME = "pull_request" ]] && BRANCH_NAME="$HEAD_REF" || BRANCH_NAME="$REF_NAME" - [[ $GITHUB_EVENT_NAME = "pull_request" ]] && COMMIT_MSG="${{ github.event.pull_request.title }}" || COMMIT_MSG=$(echo -e "${{ github.event.head_commit.message }}" | head -n 1) + [[ $GITHUB_EVENT_NAME = "pull_request" ]] && COMMIT_MSG="$PR_TITLE" || COMMIT_MSG=$(echo -e "$HEAD_COMMIT_MSG" | head -n 1) PIPELINE_PROJECT_URL="github.com/$GITHUB_REPOSITORY.git" From 2a3e2f787e253932f0f4060a6fcffbefdf9d8b55 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 14:21:59 +0800 Subject: [PATCH 02/22] [MOL-22453][SX] Add RegexHelper: shared regex parsing + safe cap, update all callers --- .../fields/masked-field/masked-field.spec.tsx | 89 +++++++++++++++++++ .../yup/custom-conditions.spec.ts | 22 +++++ .../frontend-engine/yup/yup-helper.spec.ts | 17 ++++ src/__tests__/utils/regex-helper.spec.ts | 49 ++++++++++ .../image-manager/image-manager.ts | 13 +-- .../fields/masked-field/masked-field.tsx | 56 ++++++++---- src/components/shared/error-messages.tsx | 3 + .../yup/custom-conditions/index.ts | 12 ++- src/context-providers/yup/helper.ts | 21 +++-- src/utils/index.ts | 1 + src/utils/regex-helper.ts | 21 +++++ 11 files changed, 269 insertions(+), 35 deletions(-) create mode 100644 src/__tests__/utils/regex-helper.spec.ts create mode 100644 src/utils/regex-helper.ts diff --git a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx index ef100fc61..f857f9829 100644 --- a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx +++ b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx @@ -3,7 +3,9 @@ import cloneDeep from "lodash/cloneDeep"; import merge from "lodash/merge"; import { FrontendEngine } from "../../../../components"; import { IMaskedFieldSchema } from "../../../../components/fields"; +import { ERROR_MESSAGES } from "../../../../components/shared"; import { IFrontendEngineData, IFrontendEngineRef } from "../../../../components/types"; +import { RegexHelper } from "../../../../utils"; import { ERROR_MESSAGE, FRONTEND_ENGINE_ID, @@ -90,6 +92,84 @@ describe(UI_TYPE, () => { expect(getMaskedField()).toHaveAttribute("maxLength", "5"); }); + it("should default maxLength to the safe regex length bound when maskRegex is set with no max/length validation", () => { + renderComponent({ maskRange: null, maskRegex: "/^(hello)/g" }); + + expect(getMaskedField()).toHaveAttribute("maxLength", `${RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH}`); + }); + + it("should prefer an explicit max/length validation's maxLength over the maskRegex default", () => { + renderComponent({ maskRange: null, maskRegex: "/^(hello)/g", validation: [{ max: 5 }] }); + + expect(getMaskedField()).toHaveAttribute("maxLength", "5"); + }); + + it("should not hang when a long value arrives via defaultValues", () => { + const maliciousValue = `${"a".repeat(600)}!`; + + const start = Date.now(); + renderComponent( + { maskRange: null, maskRegex: "/^(a+)+$/" }, + { defaultValues: { [COMPONENT_ID]: maliciousValue } } + ); + expect(Date.now() - start).toBeLessThan(1000); + + expect((getMaskedField() as HTMLInputElement).value.length).toBeLessThanOrEqual( + RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + ); + }); + + it("should clamp an already-loaded long value at render time when maskRegex changes at runtime, not only when the value itself changes", () => { + const maliciousValue = `${"a".repeat(600)}!`; + const withoutMaskRegex: IFrontendEngineData = merge(cloneDeep(JSON_SCHEMA), { + defaultValues: { [COMPONENT_ID]: maliciousValue }, + }); + const { rerender } = render(); + + const withMaskRegex: IFrontendEngineData = cloneDeep(withoutMaskRegex); + merge(withMaskRegex, { + sections: { section: { children: { [COMPONENT_ID]: { maskRange: null, maskRegex: "/^(a+)+$/" } } } }, + }); + + const start = Date.now(); + rerender(); + expect(Date.now() - start).toBeLessThan(1000); + + expect((getMaskedField() as HTMLInputElement).value.length).toBeLessThanOrEqual( + RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + ); + }); + + it("should reject an oversized programmatic value with a validation error when maskRegex is set but no explicit max/length rule governs the length", async () => { + const oversizedValue = "a".repeat(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + 1); + renderComponent( + { maskRange: null, maskRegex: "/^(hello)/g" }, + { defaultValues: { [COMPONENT_ID]: oversizedValue } } + ); + + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect( + getErrorMessage( + false, + ERROR_MESSAGES.MASKED_FIELD.VALUE_TOO_LONG(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH) + ) + ).toBeInTheDocument(); + expect(SUBMIT_FN).not.toHaveBeenCalled(); + }); + + it("should not reject an oversized value when an explicit max validation rule already permits that length", async () => { + const value = "a".repeat(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + 1); + renderComponent( + { maskRange: null, maskRegex: "/^(hello)/g", validation: [{ max: 1000 }] }, + { defaultValues: { [COMPONENT_ID]: value } } + ); + + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: value })); + }); + it("should support default value", async () => { const defaultValue = "hello"; renderComponent(undefined, { defaultValues: { [COMPONENT_ID]: defaultValue } }); @@ -125,6 +205,15 @@ describe(UI_TYPE, () => { expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: defaultValue })); }); + it("should not throw when maskRegex is malformed", () => { + expect(() => + renderComponent( + { maskRange: null, maskRegex: "not a /pattern/flags string [" }, + { defaultValues: { [COMPONENT_ID]: "hello" } } + ) + ).not.toThrow(); + }); + it("should render custom icons", () => { const maskIcon = "AlbumFillIcon"; const unmaskIcon = "AlbumIcon"; diff --git a/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts b/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts index 0b7b940ea..67195cba1 100644 --- a/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts +++ b/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts @@ -17,3 +17,25 @@ it.each` expect(TestHelper.getError(() => schema.validateSync(invalidValue)).message).toBe(ERROR_MESSAGE) ); }); + +describe("notMatches", () => { + const buildSchema = (regex: string) => + YupHelper.buildFieldSchema(YupHelper.mapSchemaType("string"), [ + { notMatches: regex, errorMessage: ERROR_MESSAGE }, + ]); + + it("should not throw when the regex string is malformed", () => { + const schema = buildSchema("not a /pattern/flags string ["); + + expect(() => schema.validateSync("hello")).not.toThrow(); + }); + + it("should reject an overly long value instead of testing it against the pattern", () => { + const schema = buildSchema("/^(a+)+$/"); + const maliciousValue = `${"a".repeat(600)}!`; + + const start = Date.now(); + expect(TestHelper.getError(() => schema.validateSync(maliciousValue)).message).toBe(ERROR_MESSAGE); + expect(Date.now() - start).toBeLessThan(1000); + }); +}); diff --git a/src/__tests__/components/frontend-engine/yup/yup-helper.spec.ts b/src/__tests__/components/frontend-engine/yup/yup-helper.spec.ts index cee24272d..f21738f88 100644 --- a/src/__tests__/components/frontend-engine/yup/yup-helper.spec.ts +++ b/src/__tests__/components/frontend-engine/yup/yup-helper.spec.ts @@ -248,6 +248,23 @@ describe("YupHelper", () => { ); }); + it("should not hang when input exceeds the safe length bound for a matches pattern", () => { + const schema = YupHelper.mapRules(Yup.string(), [{ matches: "/^(a+)+$/", errorMessage: ERROR_MESSAGE }]); + const maliciousValue = `${"a".repeat(1000)}!`; + + const start = Date.now(); + expect(TestHelper.getError(() => schema.validateSync(maliciousValue))?.message).toBe(ERROR_MESSAGE); + expect(Date.now() - start).toBeLessThan(1000); + }); + + it("should skip a matches rule applied to a non-string schema instead of testing the value's string coercion", () => { + const schema = YupHelper.mapRules(Yup.array(), [{ matches: "/^[a-z]+$/", errorMessage: ERROR_MESSAGE }]); + + // an array value stringifies to something that would never satisfy a filename-shaped pattern + // (e.g. "[object Object]") — applying the rule here must not reject the value on that basis + expect(() => schema.validateSync([{ fileName: "test.jpg" }])).not.toThrow(); + }); + const generateMultipleFieldSchema = (type: "string" | "number" | "boolean" | "object" | "array") => YupHelper.buildSchema({ field1: { schema: YupHelper.mapSchemaType(type), validationRules: [] }, diff --git a/src/__tests__/utils/regex-helper.spec.ts b/src/__tests__/utils/regex-helper.spec.ts new file mode 100644 index 000000000..45abc1f18 --- /dev/null +++ b/src/__tests__/utils/regex-helper.spec.ts @@ -0,0 +1,49 @@ +import { RegexHelper } from "../../utils"; + +describe("regex-helper", () => { + describe("parseMatchesPattern", () => { + it("should parse a /pattern/flags string into a RegExp", () => { + const regex = RegexHelper.parseMatchesPattern("/^hello/i"); + + expect(regex).toBeInstanceOf(RegExp); + expect(regex.source).toBe("^hello"); + expect(regex.flags).toBe("i"); + }); + + it("should fall back to treating the whole string as a pattern when it has no /pattern/flags wrapper", () => { + const regex = RegexHelper.parseMatchesPattern("hello"); + + expect(regex).toBeInstanceOf(RegExp); + expect(regex.source).toBe("hello"); + }); + + it("should return undefined instead of throwing on an invalid pattern", () => { + expect(RegexHelper.parseMatchesPattern("/[/")).toBeUndefined(); + }); + }); + + describe("safeTestRegex", () => { + it("should return false when regex is undefined", () => { + expect(RegexHelper.safeTestRegex(undefined, "hello")).toBe(false); + }); + + it("should test the value against the regex when within the safe length bound", () => { + expect(RegexHelper.safeTestRegex(/^hello/, "hello world")).toBe(true); + expect(RegexHelper.safeTestRegex(/^hello/, "goodbye world")).toBe(false); + }); + + it("should not hang and should return false when value exceeds the safe length bound", () => { + const maliciousValue = `${"a".repeat(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + 1)}!`; + + const start = Date.now(); + expect(RegexHelper.safeTestRegex(/^(a+)+$/, maliciousValue)).toBe(false); + expect(Date.now() - start).toBeLessThan(1000); + }); + + it("should return false when a short value does not match the pattern", () => { + const nearMatch = `${"a".repeat(25)}!`; + + expect(RegexHelper.safeTestRegex(/^(a+)+$/, nearMatch)).toBe(false); + }); + }); +}); diff --git a/src/components/fields/image-upload/image-manager/image-manager.ts b/src/components/fields/image-upload/image-manager/image-manager.ts index ce0e13b96..52c7e350b 100644 --- a/src/components/fields/image-upload/image-manager/image-manager.ts +++ b/src/components/fields/image-upload/image-manager/image-manager.ts @@ -1,6 +1,6 @@ import { useContext, useEffect, useRef } from "react"; import { useFormContext } from "react-hook-form"; -import { AxiosApiClient, FileHelper, ImageHelper, generateRandomId } from "../../../../utils"; +import { AxiosApiClient, FileHelper, ImageHelper, RegexHelper, generateRandomId } from "../../../../utils"; import { useFieldEvent, usePrevious } from "../../../../utils/hooks"; import { ImageContext } from "../image-context"; import { @@ -110,7 +110,7 @@ export const ImageManager = (props: IProps) => { case EImageStatus.NONE: if (filenameMatches) { const pattern = resolveMatchesPattern(filenameMatches); - if (pattern && !pattern.test(image.name)) { + if (pattern && !RegexHelper.safeTestRegex(pattern, image.name)) { setImages((prev) => { const updatedImages = [...prev]; updatedImages[index] = { @@ -257,14 +257,7 @@ export const ImageManager = (props: IProps) => { * Converts a matches string (e.g. "/^abc$/i" or "^abc$") to a RegExp. * Returns undefined if the string is invalid. */ - const resolveMatchesPattern = (matches: string): RegExp | undefined => { - try { - const parsed = matches.match(/^\/(.+)\/([gimsuy]*)$/); - return parsed ? new RegExp(parsed[1], parsed[2] || "") : new RegExp(matches); - } catch { - return undefined; - } - }; + const resolveMatchesPattern = (matches: string): RegExp | undefined => RegexHelper.parseMatchesPattern(matches); const convertImage = async (index: number, image: IImage) => { try { diff --git a/src/components/fields/masked-field/masked-field.tsx b/src/components/fields/masked-field/masked-field.tsx index 22112ce33..e680bcc60 100644 --- a/src/components/fields/masked-field/masked-field.tsx +++ b/src/components/fields/masked-field/masked-field.tsx @@ -4,9 +4,9 @@ import * as Icons from "@lifesg/react-icons"; import React, { useEffect, useState } from "react"; import * as Yup from "yup"; import { IGenericFieldProps } from ".."; -import { TestHelper } from "../../../utils"; +import { RegexHelper, TestHelper } from "../../../utils"; import { useValidationConfig } from "../../../utils/hooks"; -import { Warning } from "../../shared"; +import { ERROR_MESSAGES, Warning } from "../../shared"; import { IMaskedFieldSchema } from "./types"; export const MaskedField = (props: IGenericFieldProps) => { @@ -24,34 +24,60 @@ export const MaskedField = (props: IGenericFieldProps) => { ...otherProps } = props; - const [stateValue, setStateValue] = useState(value || ""); + const getMaskRegexSafeLength = (): number | undefined => { + if (!maskRegex) return undefined; + const maxRule = validation?.find((rule) => "max" in rule); + const lengthRule = validation?.find((rule) => "length" in rule); + if (maxRule?.max > 0) return maxRule.max; + if (lengthRule?.length > 0) return lengthRule.length; + return RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH; + }; + + const safeLength = getMaskRegexSafeLength(); + + const clampValue = (val: string | number | undefined): string => { + const stringVal = val !== undefined && val !== null ? `${val}` : ""; + return safeLength !== undefined ? stringVal.slice(0, safeLength) : stringVal; + }; + + const [stateValue, setStateValue] = useState(() => clampValue(value)); const [derivedAttributes, setDerivedAttributes] = useState({}); const { setFieldValidationConfig } = useValidationConfig(); + const displayedValue = clampValue(stateValue); + // ============================================================================= // EFFECTS // ============================================================================= useEffect(() => { - setFieldValidationConfig(id, Yup.string(), validation); - const maxRule = validation?.find((rule) => "max" in rule); const lengthRule = validation?.find((rule) => "length" in rule); + + let schema = Yup.string(); + if (maskRegex && !maxRule && !lengthRule) { + schema = schema.max( + RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH, + ERROR_MESSAGES.MASKED_FIELD.VALUE_TOO_LONG(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH) + ); + } + setFieldValidationConfig(id, schema, validation); + const attributes = { ...derivedAttributes }; if (maxRule?.max > 0) { attributes.maxLength = maxRule.max; } else if (lengthRule?.length > 0) { attributes.maxLength = lengthRule.length; + } else if (maskRegex) { + attributes.maxLength = RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH; } setDerivedAttributes(attributes); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [validation]); + }, [validation, maskRegex]); useEffect(() => { - if (value !== stateValue) { - setStateValue(value || ""); - } + setStateValue(clampValue(value)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value]); + }, [safeLength, value]); // ============================================================================= // EVENT HANDLERS @@ -65,12 +91,11 @@ export const MaskedField = (props: IGenericFieldProps) => { // ============================================================================= const getRegex = () => { if (!maskRegex) return; - try { - const matches = maskRegex.match(/\/(.*)\/([a-z]+)?/); - return new RegExp(matches[1], matches[2]); - } catch (err) { + const regex = RegexHelper.parseMatchesPattern(maskRegex); + if (!regex) { console.warn(`invalid regex pattern: ${maskRegex}`); } + return regex; }; // ============================================================================= @@ -89,11 +114,12 @@ export const MaskedField = (props: IGenericFieldProps) => { {...otherSchema} {...otherProps} {...derivedAttributes} + key={maskRegex ?? "no-mask-regex"} id={id} data-testid={TestHelper.generateId(id, uiType)} label={formattedLabel} onChange={handleChange} - value={stateValue} + value={displayedValue} errorMessage={error?.message} maskRegex={getRegex()} iconMask={renderIcon(iconMask)} diff --git a/src/components/shared/error-messages.tsx b/src/components/shared/error-messages.tsx index b83852d6e..347548d5e 100644 --- a/src/components/shared/error-messages.tsx +++ b/src/components/shared/error-messages.tsx @@ -116,6 +116,9 @@ export const ERROR_MESSAGES = { LOCATION: { MUST_HAVE_POSTAL_CODE: "Selected location must have postal code.", }, + MASKED_FIELD: { + VALUE_TOO_LONG: (maxLength: number) => `Value exceeds the maximum allowed length of ${maxLength} characters.`, + }, ARRAY_FIELD: { INVALID: "One or more of the sections is incomplete", REQUIRED: "At least one section must be filled in", diff --git a/src/context-providers/yup/custom-conditions/index.ts b/src/context-providers/yup/custom-conditions/index.ts index 550296f5f..0f49f2cae 100644 --- a/src/context-providers/yup/custom-conditions/index.ts +++ b/src/context-providers/yup/custom-conditions/index.ts @@ -7,7 +7,7 @@ import { YupHelper } from "../helper"; import "./html-safe"; import "./uinfin"; import "./uen"; -import { DateTimeHelper } from "../../../utils"; +import { DateTimeHelper, RegexHelper } from "../../../utils"; import { IDaysRangeRule, IWhitespaceRule } from "../types"; /** @@ -23,8 +23,14 @@ YupHelper.addCondition("string", "notMatches", (value: string, regex: string) => if (isEmptyValue(value)) { return true; } - const matches = regex.match(/\/(.*)\/([a-z]+)?/); - const parsedRegex = new RegExp(matches[1], matches[2]); + const parsedRegex = RegexHelper.parseMatchesPattern(regex); + if (!parsedRegex) { + console.warn(`invalid regex pattern: ${regex}`); + return true; + } + if (value.length > RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH) { + return false; + } return !parsedRegex.test(value); }); /** @deprecated */ diff --git a/src/context-providers/yup/helper.ts b/src/context-providers/yup/helper.ts index 90f147efd..d45837d7b 100644 --- a/src/context-providers/yup/helper.ts +++ b/src/context-providers/yup/helper.ts @@ -1,6 +1,7 @@ import * as Yup from "yup"; import { ObjectShape } from "yup/lib/object"; import { ERROR_MESSAGES } from "../../components/shared"; +import { RegexHelper } from "../../utils"; import { IFieldYupConfig, IYupConditionalValidationRule, @@ -187,13 +188,19 @@ export namespace YupHelper { break; case !!rule.matches: { - const matches = rule.matches.match(/\/(.*)\/([a-z]+)?/); - try { - yupSchema = (yupSchema as Yup.StringSchema).matches( - new RegExp(matches[1], matches[2]), - rule.errorMessage - ); - } catch (error) { + if (yupSchema.type !== "string") { + console.warn(`error applying "${ruleKey}" condition to ${yupSchema.type} schema`); + break; + } + const regex = RegexHelper.parseMatchesPattern(rule.matches); + if (regex) { + yupSchema = (yupSchema as Yup.StringSchema).test({ + name: "matches", + message: rule.errorMessage, + test: (value) => + value === undefined || value === null || RegexHelper.safeTestRegex(regex, value), + }); + } else { console.warn(`error applying "${ruleKey}" condition to ${yupSchema.type} schema`); } } diff --git a/src/utils/index.ts b/src/utils/index.ts index f3a426a54..c0e556f61 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,3 +9,4 @@ export * from "./object-helper"; export * from "./test-helper"; export * from "./types"; export * from "./window-helper"; +export * from "./regex-helper"; diff --git a/src/utils/regex-helper.ts b/src/utils/regex-helper.ts new file mode 100644 index 000000000..80fc07bad --- /dev/null +++ b/src/utils/regex-helper.ts @@ -0,0 +1,21 @@ +export namespace RegexHelper { + export const MAX_SAFE_PATTERN_INPUT_LENGTH = 500; + + /** parses a `/pattern/flags`-style string into a RegExp, matching the convention already used + * across the codebase for schema-authored regex config. Returns undefined instead of throwing + * on an invalid pattern. */ + export const parseMatchesPattern = (pattern: string): RegExp | undefined => { + try { + const parsed = pattern.match(/^\/(.+)\/([a-z]*)$/i); + return parsed ? new RegExp(parsed[1], parsed[2]) : new RegExp(pattern); + } catch { + return undefined; + } + }; + + export const safeTestRegex = (regex: RegExp | undefined, value: string): boolean => { + if (!regex) return false; + if (value.length > MAX_SAFE_PATTERN_INPUT_LENGTH) return false; + return regex.test(value); + }; +} From 5d79c6a162cad06592be936e9562cff6fa219e30 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 14:21:59 +0800 Subject: [PATCH 03/22] [MOL-22453][SX] sanitize-html: restrict allowedAttributes to safe defaults (no wildcard) --- .../custom/filter/filter-checkbox.spec.tsx | 9 +++++++++ src/__tests__/components/elements/text/text.spec.tsx | 12 ++++++++++++ .../filter/filter-checkbox/filter-checkbox.tsx | 3 ++- src/components/elements/text/text.tsx | 5 ++++- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/__tests__/components/custom/filter/filter-checkbox.spec.tsx b/src/__tests__/components/custom/filter/filter-checkbox.spec.tsx index 0c3a68663..4bfeffc85 100644 --- a/src/__tests__/components/custom/filter/filter-checkbox.spec.tsx +++ b/src/__tests__/components/custom/filter/filter-checkbox.spec.tsx @@ -84,6 +84,15 @@ describe(REFERENCE_KEY, () => { expect(SUBMIT_FN).toBeCalledWith(expect.objectContaining({ [COMPONENT_ID]: defaultValues })); }); + it("should strip event handler attributes from option labels", () => { + renderComponent({ + options: [{ label: 'Apple label', value: "Apple" }], + }); + + const spanElement = screen.getByText("Apple label"); + expect(spanElement).not.toHaveAttribute("onclick"); + }); + it("should be able to render hint", () => { renderComponent({ label: { diff --git a/src/__tests__/components/elements/text/text.spec.tsx b/src/__tests__/components/elements/text/text.spec.tsx index 272f3fe7a..58467ccba 100644 --- a/src/__tests__/components/elements/text/text.spec.tsx +++ b/src/__tests__/components/elements/text/text.spec.tsx @@ -111,6 +111,18 @@ describe(UI_TYPE, () => { expect(screen.getByText("This is a HTML string")).toBeInTheDocument(); }); + it("should strip event handler attributes from an otherwise-allowed image tag", () => { + renderComponent({ + className: "text-element", + children: '\'broken', + }); + + const imgElement = screen.getByAltText("broken image"); + expect(imgElement).toBeInTheDocument(); + expect(imgElement).not.toHaveAttribute("onerror"); + expect(document.querySelector(".text-element").innerHTML).not.toContain("onerror"); + }); + it("should be able to sanitize HTML string", () => { renderComponent({ className: "text-element", diff --git a/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx b/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx index a542314eb..6011bf9a7 100644 --- a/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx +++ b/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx @@ -2,6 +2,7 @@ import { Filter } from "@lifesg/react-design-system/filter"; import { useEffect, useState } from "react"; import { useFormContext } from "react-hook-form"; import useDeepCompareEffect from "use-deep-compare-effect"; +import sanitizeHtml from "sanitize-html"; import { TestHelper } from "../../../../utils"; import { Sanitize } from "../../../shared"; import { IGenericCustomFieldProps } from "../../types"; @@ -61,7 +62,7 @@ export const FilterCheckbox = (props: IGenericCustomFieldProps ( - + {item.label} )} diff --git a/src/components/elements/text/text.tsx b/src/components/elements/text/text.tsx index acb6fe34c..f6089e32b 100644 --- a/src/components/elements/text/text.tsx +++ b/src/components/elements/text/text.tsx @@ -61,7 +61,10 @@ export const Text = (props: IGenericElementProps) => { // ============================================================================= const sanitizeOptions: IOptions = { allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]), - allowedAttributes: false, + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + img: ["src", "alt", "width", "height"], + }, }; const renderText = (): JSX.Element[] | JSX.Element | string[] | string => { From 0a11d733788bcb9d654970890c3fb3183914bb10 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 14:22:00 +0800 Subject: [PATCH 04/22] [MOL-22453][SX] ButtonField: restrict href to http/https/mailto/tel schemes only --- src/__tests__/components/fields/button/button.spec.tsx | 8 +++++--- src/components/fields/button/button.tsx | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/__tests__/components/fields/button/button.spec.tsx b/src/__tests__/components/fields/button/button.spec.tsx index 178772c41..9cfc9f3b1 100644 --- a/src/__tests__/components/fields/button/button.spec.tsx +++ b/src/__tests__/components/fields/button/button.spec.tsx @@ -125,9 +125,11 @@ describe("button", () => { }); it.each` - scenario | href - ${"should not navigate when href is not provided"} | ${undefined} - ${"should not navigate when href is invalid"} | ${"invalid-url"} + scenario | href + ${"should not navigate when href is not provided"} | ${undefined} + ${"should not navigate when href is invalid"} | ${"invalid-url"} + ${"should not navigate when href uses javascript: scheme"} | ${"javascript:alert(1)"} + ${"should not navigate when href uses data: scheme"} | ${"data:text/html,"} `("$scenario", ({ href }) => { renderComponent({ overrideButton: { ...(href && { href }) } }); fireEvent.click(getField("button", COMPONENT_LABEL)); diff --git a/src/components/fields/button/button.tsx b/src/components/fields/button/button.tsx index 8dd1aec64..ed0644823 100644 --- a/src/components/fields/button/button.tsx +++ b/src/components/fields/button/button.tsx @@ -5,6 +5,8 @@ import { IGenericFieldProps } from ".."; import { IButtonSchema, TLinkTarget } from "./types"; import { useFieldEvent } from "../../../utils/hooks"; +const ALLOWED_URL_SCHEMES = ["http:", "https:", "mailto:", "tel:"]; + export const ButtonField = (props: IGenericFieldProps) => { // ============================================================================= // CONST, STATE, REF @@ -37,7 +39,7 @@ export const ButtonField = (props: IGenericFieldProps) => { const isValidUrl = (url: string): boolean => { try { - return !!new URL(url); + return ALLOWED_URL_SCHEMES.includes(new URL(url).protocol); } catch { return false; } From 10809bc6152af7f0e717177fb99ae14b3f861e08 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 14:22:00 +0800 Subject: [PATCH 05/22] [MOL-22453][SX] Iframe: validate postMessage origin against iframe src (default on) --- .../components/custom/iframe/iframe.spec.tsx | 60 +++++++++++++++++++ src/components/custom/iframe/iframe.tsx | 24 +++++--- src/utils/hooks/use-iframe-message.ts | 5 +- 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/__tests__/components/custom/iframe/iframe.spec.tsx b/src/__tests__/components/custom/iframe/iframe.spec.tsx index beb1c1237..e318cf281 100644 --- a/src/__tests__/components/custom/iframe/iframe.spec.tsx +++ b/src/__tests__/components/custom/iframe/iframe.spec.tsx @@ -120,6 +120,66 @@ describe("iframe", () => { expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: "hello world" })); }); + describe("postMessage origin validation", () => { + const sendPostMessageFromOrigin = (origin: string, type: EPostMessageEvent, payload?: unknown) => { + fireEvent(window, new MessageEvent("message", { data: { type, payload }, origin })); + }; + + it("should ignore a setValue postMessage from an origin that does not match src", async () => { + renderComponent({ validationTimeout: -1 }); + + sendPostMessageFromOrigin("https://attacker.example", EPostMessageEvent.SET_VALUE, "hello world"); + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith({}); + }); + + it("should accept a setValue postMessage from the origin matching src", async () => { + renderComponent({ validationTimeout: -1 }); + + sendPostMessageFromOrigin(IFRAME_SRC, EPostMessageEvent.SET_VALUE, "hello world"); + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: "hello world" })); + }); + + it("should derive the origin for a relative src (resolved against the current page) and still ignore mismatched origins", async () => { + renderComponent({ src: "/embedded/form", validationTimeout: -1 }); + + sendPostMessageFromOrigin("https://attacker.example", EPostMessageEvent.SET_VALUE, "hello world"); + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith({}); + }); + + it("should accept a setValue postMessage matching the origin derived from a relative src", async () => { + renderComponent({ src: "/embedded/form", validationTimeout: -1 }); + + sendPostMessageFromOrigin("http://localhost", EPostMessageEvent.SET_VALUE, "hello world"); + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: "hello world" })); + }); + + it("should derive the origin for a protocol-relative src and still ignore mismatched origins", async () => { + renderComponent({ src: "//localhost/embedded/form", validationTimeout: -1 }); + + sendPostMessageFromOrigin("https://attacker.example", EPostMessageEvent.SET_VALUE, "hello world"); + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith({}); + }); + + it("should reject every postMessage when src cannot be resolved to a valid http(s) origin", async () => { + renderComponent({ src: "javascript:alert(1)", validationTimeout: -1 }); + + sendPostMessageFromOrigin("https://attacker.example", EPostMessageEvent.SET_VALUE, "hello world"); + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith({}); + }); + }); + describe("load", () => { it("should fire a loading event when iframe starts loading", () => { const testFn = jest.fn(); diff --git a/src/components/custom/iframe/iframe.tsx b/src/components/custom/iframe/iframe.tsx index cb5c0a36a..c9a3c96c9 100644 --- a/src/components/custom/iframe/iframe.tsx +++ b/src/components/custom/iframe/iframe.tsx @@ -42,8 +42,11 @@ export const Iframe = (props: IGenericCustomFieldProps) => { // ========================================================================= const getTargetOriginFromSrc = useCallback(() => { try { - const parsedUrl = new URL(src); - return `${parsedUrl.protocol}//${parsedUrl.host}`; + const parsedUrl = new URL(src, window.location.href); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + return null; + } + return parsedUrl.origin; } catch (error) { console.error("Invalid URL:", error); return null; @@ -121,11 +124,14 @@ export const Iframe = (props: IGenericCustomFieldProps) => { // ========================================================================= // POSTMESSAGE HANDLERS // ========================================================================= + const allowedOrigin = getTargetOriginFromSrc(); + useIframeMessage( EPostMessageEvent.TRIGGER_SYNC, useCallback(() => { iframePostMessage({ type: EPostMessageEvent.SYNC, payload: { error, id, value } }); - }, [error, id, value, iframePostMessage]) + }, [error, id, value, iframePostMessage]), + allowedOrigin ); useIframeMessage<{ width?: number | undefined; height?: number | undefined }>( @@ -135,7 +141,8 @@ export const Iframe = (props: IGenericCustomFieldProps) => { width: e.data.payload?.width, height: e.data.payload?.height, }); - }, []) + }, []), + allowedOrigin ); useIframeMessage( @@ -145,7 +152,8 @@ export const Iframe = (props: IGenericCustomFieldProps) => { formContext.setValue(id, e.data.payload, { shouldDirty: true }); }, [formContext, id] - ) + ), + allowedOrigin ); useIframeMessage( @@ -160,14 +168,16 @@ export const Iframe = (props: IGenericCustomFieldProps) => { clearAsyncValidation(); }, [clearAsyncValidation] - ) + ), + allowedOrigin ); useIframeMessage( EPostMessageEvent.LOADED, useCallback(() => { dispatchFieldEvent("loaded", id); - }, [dispatchFieldEvent, id]) + }, [dispatchFieldEvent, id]), + allowedOrigin ); // ========================================================================= diff --git a/src/utils/hooks/use-iframe-message.ts b/src/utils/hooks/use-iframe-message.ts index 13d673e6a..fe0a3d701 100644 --- a/src/utils/hooks/use-iframe-message.ts +++ b/src/utils/hooks/use-iframe-message.ts @@ -2,9 +2,10 @@ import { useEffect } from "react"; type MessageHandler = (event: MessageEvent<{ payload: T }>) => void; -export const useIframeMessage = (eventType: string, handler: MessageHandler) => { +export const useIframeMessage = (eventType: string, handler: MessageHandler, allowedOrigin?: string | null) => { useEffect(() => { const eventHandler = (event: MessageEvent) => { + if (allowedOrigin !== undefined && event.origin !== allowedOrigin) return; if (event.data.type === eventType) { handler(event); } @@ -17,5 +18,5 @@ export const useIframeMessage = (eventType: string, handler: MessageHandler { window.removeEventListener("message", eventHandler); }; - }, [eventType, handler]); + }, [eventType, handler, allowedOrigin]); }; From d22c4fd364bf97bb6daf88c5fb496566cf92da01 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 14:22:00 +0800 Subject: [PATCH 06/22] [MOL-22453][SX] LocationField: escape search query before use in RegExp (prevent ReDoS) --- .../location-search/helper.spec.ts | 29 +++++++++++++++++++ .../location-modal/location-search/helper.ts | 9 +++++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts diff --git a/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts b/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts new file mode 100644 index 000000000..a5cdfd0bc --- /dev/null +++ b/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts @@ -0,0 +1,29 @@ +import { boldResultsWithQuery } from "../../../../../../components/fields/location-field/location-modal/location-search/helper"; +import { IResultListItem } from "../../../../../../components/fields/location-field/types"; + +const buildResult = (address: string): IResultListItem => ({ + address, + displayAddressText: undefined, +}); + +describe("boldResultsWithQuery", () => { + it("should bold the matching portion of the address", () => { + const [result] = boldResultsWithQuery([buildResult("123 Example Street")], "Example"); + + expect(result.displayAddressText).toBe('123 Example Street'); + }); + + it("should treat regex metacharacters in the query as literal characters", () => { + const [result] = boldResultsWithQuery([buildResult("Blk 5 (Example)")], "(Example)"); + + expect(result.displayAddressText).toBe('Blk 5 (Example)'); + }); + + it("should complete within a reasonable time for any query string", () => { + const start = Date.now(); + expect(() => + boldResultsWithQuery([buildResult("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!")], "(a+)+$") + ).not.toThrow(); + expect(Date.now() - start).toBeLessThan(1000); + }); +}); diff --git a/src/components/fields/location-field/location-modal/location-search/helper.ts b/src/components/fields/location-field/location-modal/location-search/helper.ts index 78c26275c..98031c93b 100644 --- a/src/components/fields/location-field/location-modal/location-search/helper.ts +++ b/src/components/fields/location-field/location-modal/location-search/helper.ts @@ -4,8 +4,15 @@ export const pagination = (array: T[], pageSize: number, pageNum: number) => return array.slice((pageNum - 1) * pageSize, pageNum * pageSize); }; +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + export const boldResultsWithQuery = (arr: IResultListItem[], query: string) => { - const regex = new RegExp(query, "gi"); + let regex: RegExp; + try { + regex = new RegExp(escapeRegExp(query), "gi"); + } catch { + return arr; + } return arr.map((obj) => { const newAddress = (obj.displayAddressText || obj.address).replace( regex, From 3beff2990ea7f7779decf7294d02232ebc00c694 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:55 +0800 Subject: [PATCH 07/22] [MOL-22453][SX] StyleHelper: strip @import and url() from schema-authored cssText --- src/__tests__/utils/style-helper.spec.ts | 26 +++++++++++++++++++ .../image-review/image-review.styles.ts | 3 ++- .../location-modal/location-modal.styles.ts | 3 ++- src/utils/index.ts | 1 + src/utils/style-helper.ts | 8 ++++++ 5 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/utils/style-helper.spec.ts create mode 100644 src/utils/style-helper.ts diff --git a/src/__tests__/utils/style-helper.spec.ts b/src/__tests__/utils/style-helper.spec.ts new file mode 100644 index 000000000..015c783a4 --- /dev/null +++ b/src/__tests__/utils/style-helper.spec.ts @@ -0,0 +1,26 @@ +import { StyleHelper } from "../../utils"; + +describe("style-helper", () => { + describe("sanitizeStyleString", () => { + it("should strip @import rules", () => { + const result = StyleHelper.sanitizeStyleString( + 'padding: 1rem; @import url("https://evil.example.com/x.css");' + ); + + expect(result).not.toContain("@import"); + expect(result).toContain("padding: 1rem;"); + }); + + it("should strip url() references", () => { + const result = StyleHelper.sanitizeStyleString('background: url("https://evil.example.com/track.png");'); + + expect(result).not.toContain("url("); + }); + + it("should leave a style string with neither construct unchanged", () => { + const value = "padding: 1rem; margin: 2rem;"; + + expect(StyleHelper.sanitizeStyleString(value)).toBe(value); + }); + }); +}); diff --git a/src/components/fields/image-upload/image-review/image-review.styles.ts b/src/components/fields/image-upload/image-review/image-review.styles.ts index 333a12a8e..804b35ff0 100644 --- a/src/components/fields/image-upload/image-review/image-review.styles.ts +++ b/src/components/fields/image-upload/image-review/image-review.styles.ts @@ -9,6 +9,7 @@ import { EraserIcon } from "@lifesg/react-icons/eraser"; import { PencilIcon } from "@lifesg/react-icons/pencil"; import { PencilStrokeIcon } from "@lifesg/react-icons/pencil-stroke"; import styled, { css } from "styled-components"; +import { StyleHelper } from "../../../../utils"; interface IModalBoxStyle { imageReviewModalStyles?: string | undefined; @@ -19,7 +20,7 @@ export const ModalBox = styled(Modal.Box)` max-height: fit-content; ${({ imageReviewModalStyles }) => { - if (imageReviewModalStyles) return `${imageReviewModalStyles}`; + if (imageReviewModalStyles) return StyleHelper.sanitizeStyleString(imageReviewModalStyles); }} ${MediaQuery.MinWidth.tablet} { diff --git a/src/components/fields/location-field/location-modal/location-modal.styles.ts b/src/components/fields/location-field/location-modal/location-modal.styles.ts index 3425c08cc..2fb89897f 100644 --- a/src/components/fields/location-field/location-modal/location-modal.styles.ts +++ b/src/components/fields/location-field/location-modal/location-modal.styles.ts @@ -1,6 +1,7 @@ import { MediaQuery, MediaWidths } from "@lifesg/react-design-system/media"; import { Modal } from "@lifesg/react-design-system/modal"; import styled from "styled-components"; +import { StyleHelper } from "../../../../utils"; import { TPanelInputMode } from "../types"; import { LocationPicker } from "./location-picker"; @@ -22,7 +23,7 @@ export const ModalBox = styled(Modal.Box)` z-index: 1; ${({ locationModalStyles }) => { - if (locationModalStyles) return `${locationModalStyles}`; + if (locationModalStyles) return StyleHelper.sanitizeStyleString(locationModalStyles); }} ${MediaQuery.MaxWidth.tablet} { diff --git a/src/utils/index.ts b/src/utils/index.ts index c0e556f61..070147261 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -10,3 +10,4 @@ export * from "./test-helper"; export * from "./types"; export * from "./window-helper"; export * from "./regex-helper"; +export * from "./style-helper"; diff --git a/src/utils/style-helper.ts b/src/utils/style-helper.ts new file mode 100644 index 000000000..c449cd0a6 --- /dev/null +++ b/src/utils/style-helper.ts @@ -0,0 +1,8 @@ +export namespace StyleHelper { + /** strips the two concrete network-side-channel risks from a schema-authored CSS string before it's + * assigned to an element's style.cssText: @import (loads a remote stylesheet) and url() (references + * a remote resource). Not a full CSS parser/allowlist — legitimate uses of this styling hook + * (padding/margin tweaks) need neither construct. */ + export const sanitizeStyleString = (value: string): string => + value.replace(/@import[^;]*;?/gi, "").replace(/url\s*\([^)]*\)/gi, ""); +} From 344ff6e662dc9e8b96cf5864b0299c1fb90f5599 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:55 +0800 Subject: [PATCH 08/22] [MOL-22453][SX] ImageUpload: fix matches rule incorrectly blocking valid file submissions --- .../fields/image-upload/image-upload.spec.tsx | 31 +++++++++++++++++++ .../fields/image-upload/image-upload.tsx | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx index 354b2b5ae..c998d7cfa 100644 --- a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx +++ b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx @@ -339,6 +339,22 @@ describe("image-upload", () => { await waitFor(() => expect(uploadSpy).toBeCalledTimes(1)); }); + it("should be able to submit a valid file when a matches rule is configured", async () => { + await renderComponent({ + files: [FILE_1], // "test.jpg" — lowercase alphanumeric + dot + overrideField: { validation: [{ matches: MATCHES_PATTERN, errorMessage: ERROR_MESSAGE }] }, + uploadType: "input", + }); + + await waitFor(() => expect(uploadSpy).toBeCalledTimes(1)); + await waitFor(() => fireEvent.click(getSubmitButton())); + expect(SUBMIT_FN).toHaveBeenCalledWith( + expect.objectContaining({ + field: expect.arrayContaining([expect.objectContaining({ fileName: FILE_1.name })]), + }) + ); + }); + it("should exclude invalid filename files from form submission", async () => { await renderComponent({ files: [INVALID_FILE], @@ -370,6 +386,21 @@ describe("image-upload", () => { }) ); }); + + it("should not hang when matching a long filename against a regex pattern", async () => { + const maliciousFile = new File(["file"], `${"a".repeat(600)}!.jpg`, { type: "image/jpeg" }); + + const start = Date.now(); + await renderComponent({ + files: [maliciousFile], + overrideField: { validation: [{ matches: "/^(a+)+$/", errorMessage: ERROR_MESSAGE }] }, + uploadType: "input", + }); + await waitFor(() => expect(screen.getByText(ERROR_MESSAGE)).toBeInTheDocument()); + + expect(Date.now() - start).toBeLessThan(1000); + expect(uploadSpy).not.toBeCalled(); + }); }); }); diff --git a/src/components/fields/image-upload/image-upload.tsx b/src/components/fields/image-upload/image-upload.tsx index 844987b7b..93d3ccfb5 100644 --- a/src/components/fields/image-upload/image-upload.tsx +++ b/src/components/fields/image-upload/image-upload.tsx @@ -154,7 +154,7 @@ export const ImageUploadInner = (props: IGenericFieldProps) ); } ), - validation + validation?.filter((rule) => !("matches" in rule)) ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [validation]); From 3509eaccd12e7291cacf8276c496bf3052d7a9b3 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:55 +0800 Subject: [PATCH 09/22] [MOL-22453][SX] FilterCheckbox: omit sanitizeOptions (equivalent to default) --- .../custom/filter/filter-checkbox/filter-checkbox.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx b/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx index 6011bf9a7..a1630e929 100644 --- a/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx +++ b/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx @@ -2,7 +2,6 @@ import { Filter } from "@lifesg/react-design-system/filter"; import { useEffect, useState } from "react"; import { useFormContext } from "react-hook-form"; import useDeepCompareEffect from "use-deep-compare-effect"; -import sanitizeHtml from "sanitize-html"; import { TestHelper } from "../../../../utils"; import { Sanitize } from "../../../shared"; import { IGenericCustomFieldProps } from "../../types"; @@ -62,7 +61,7 @@ export const FilterCheckbox = (props: IGenericCustomFieldProps ( - + {item.label} )} From 4d59969b84dae8b44bd33ba12dadc1fab75a8893 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:55 +0800 Subject: [PATCH 10/22] [MOL-22453][SX] yup matches: add comment explaining non-string guard --- src/context-providers/yup/helper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/context-providers/yup/helper.ts b/src/context-providers/yup/helper.ts index d45837d7b..e62ea3a18 100644 --- a/src/context-providers/yup/helper.ts +++ b/src/context-providers/yup/helper.ts @@ -188,6 +188,7 @@ export namespace YupHelper { break; case !!rule.matches: { + // "matches" tests the field's own value as a string; skip for non-string values if (yupSchema.type !== "string") { console.warn(`error applying "${ruleKey}" condition to ${yupSchema.type} schema`); break; From f1098b4837ad2c88b7a37e81db5c20fe6d0c5dbc Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:55 +0800 Subject: [PATCH 11/22] [MOL-22453][SX] RegexHelper: rename parseMatchesPattern to compile --- src/__tests__/utils/regex-helper.spec.ts | 8 ++++---- .../fields/image-upload/image-manager/image-manager.ts | 2 +- src/components/fields/masked-field/masked-field.tsx | 2 +- src/context-providers/yup/custom-conditions/index.ts | 2 +- src/context-providers/yup/helper.ts | 2 +- src/utils/regex-helper.ts | 5 +---- 6 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/__tests__/utils/regex-helper.spec.ts b/src/__tests__/utils/regex-helper.spec.ts index 45abc1f18..344aaed49 100644 --- a/src/__tests__/utils/regex-helper.spec.ts +++ b/src/__tests__/utils/regex-helper.spec.ts @@ -1,9 +1,9 @@ import { RegexHelper } from "../../utils"; describe("regex-helper", () => { - describe("parseMatchesPattern", () => { + describe("compile", () => { it("should parse a /pattern/flags string into a RegExp", () => { - const regex = RegexHelper.parseMatchesPattern("/^hello/i"); + const regex = RegexHelper.compile("/^hello/i"); expect(regex).toBeInstanceOf(RegExp); expect(regex.source).toBe("^hello"); @@ -11,14 +11,14 @@ describe("regex-helper", () => { }); it("should fall back to treating the whole string as a pattern when it has no /pattern/flags wrapper", () => { - const regex = RegexHelper.parseMatchesPattern("hello"); + const regex = RegexHelper.compile("hello"); expect(regex).toBeInstanceOf(RegExp); expect(regex.source).toBe("hello"); }); it("should return undefined instead of throwing on an invalid pattern", () => { - expect(RegexHelper.parseMatchesPattern("/[/")).toBeUndefined(); + expect(RegexHelper.compile("/[/")).toBeUndefined(); }); }); diff --git a/src/components/fields/image-upload/image-manager/image-manager.ts b/src/components/fields/image-upload/image-manager/image-manager.ts index 52c7e350b..53b0a8e90 100644 --- a/src/components/fields/image-upload/image-manager/image-manager.ts +++ b/src/components/fields/image-upload/image-manager/image-manager.ts @@ -257,7 +257,7 @@ export const ImageManager = (props: IProps) => { * Converts a matches string (e.g. "/^abc$/i" or "^abc$") to a RegExp. * Returns undefined if the string is invalid. */ - const resolveMatchesPattern = (matches: string): RegExp | undefined => RegexHelper.parseMatchesPattern(matches); + const resolveMatchesPattern = (matches: string): RegExp | undefined => RegexHelper.compile(matches); const convertImage = async (index: number, image: IImage) => { try { diff --git a/src/components/fields/masked-field/masked-field.tsx b/src/components/fields/masked-field/masked-field.tsx index e680bcc60..53d3b9742 100644 --- a/src/components/fields/masked-field/masked-field.tsx +++ b/src/components/fields/masked-field/masked-field.tsx @@ -91,7 +91,7 @@ export const MaskedField = (props: IGenericFieldProps) => { // ============================================================================= const getRegex = () => { if (!maskRegex) return; - const regex = RegexHelper.parseMatchesPattern(maskRegex); + const regex = RegexHelper.compile(maskRegex); if (!regex) { console.warn(`invalid regex pattern: ${maskRegex}`); } diff --git a/src/context-providers/yup/custom-conditions/index.ts b/src/context-providers/yup/custom-conditions/index.ts index 0f49f2cae..3f6d2e23a 100644 --- a/src/context-providers/yup/custom-conditions/index.ts +++ b/src/context-providers/yup/custom-conditions/index.ts @@ -23,7 +23,7 @@ YupHelper.addCondition("string", "notMatches", (value: string, regex: string) => if (isEmptyValue(value)) { return true; } - const parsedRegex = RegexHelper.parseMatchesPattern(regex); + const parsedRegex = RegexHelper.compile(regex); if (!parsedRegex) { console.warn(`invalid regex pattern: ${regex}`); return true; diff --git a/src/context-providers/yup/helper.ts b/src/context-providers/yup/helper.ts index e62ea3a18..43b0e9d6b 100644 --- a/src/context-providers/yup/helper.ts +++ b/src/context-providers/yup/helper.ts @@ -193,7 +193,7 @@ export namespace YupHelper { console.warn(`error applying "${ruleKey}" condition to ${yupSchema.type} schema`); break; } - const regex = RegexHelper.parseMatchesPattern(rule.matches); + const regex = RegexHelper.compile(rule.matches); if (regex) { yupSchema = (yupSchema as Yup.StringSchema).test({ name: "matches", diff --git a/src/utils/regex-helper.ts b/src/utils/regex-helper.ts index 80fc07bad..17d379a47 100644 --- a/src/utils/regex-helper.ts +++ b/src/utils/regex-helper.ts @@ -1,10 +1,7 @@ export namespace RegexHelper { export const MAX_SAFE_PATTERN_INPUT_LENGTH = 500; - /** parses a `/pattern/flags`-style string into a RegExp, matching the convention already used - * across the codebase for schema-authored regex config. Returns undefined instead of throwing - * on an invalid pattern. */ - export const parseMatchesPattern = (pattern: string): RegExp | undefined => { + export const compile = (pattern: string): RegExp | undefined => { try { const parsed = pattern.match(/^\/(.+)\/([a-z]*)$/i); return parsed ? new RegExp(parsed[1], parsed[2]) : new RegExp(pattern); From 9adca3d5b446fc45a81c8bb1929e005e05c07b3c Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:55 +0800 Subject: [PATCH 12/22] [MOL-22453][SX] RegexHelper: rename MAX_SAFE_PATTERN_INPUT_LENGTH to MAX_MATCHES_INPUT_LENGTH, bump to 1000 --- .../fields/masked-field/masked-field.spec.tsx | 12 ++++++------ src/__tests__/utils/regex-helper.spec.ts | 2 +- src/components/fields/masked-field/masked-field.tsx | 8 ++++---- src/context-providers/yup/custom-conditions/index.ts | 2 +- src/utils/regex-helper.ts | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx index f857f9829..6a764c903 100644 --- a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx +++ b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx @@ -95,7 +95,7 @@ describe(UI_TYPE, () => { it("should default maxLength to the safe regex length bound when maskRegex is set with no max/length validation", () => { renderComponent({ maskRange: null, maskRegex: "/^(hello)/g" }); - expect(getMaskedField()).toHaveAttribute("maxLength", `${RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH}`); + expect(getMaskedField()).toHaveAttribute("maxLength", `${RegexHelper.MAX_MATCHES_INPUT_LENGTH}`); }); it("should prefer an explicit max/length validation's maxLength over the maskRegex default", () => { @@ -115,7 +115,7 @@ describe(UI_TYPE, () => { expect(Date.now() - start).toBeLessThan(1000); expect((getMaskedField() as HTMLInputElement).value.length).toBeLessThanOrEqual( - RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + RegexHelper.MAX_MATCHES_INPUT_LENGTH ); }); @@ -136,12 +136,12 @@ describe(UI_TYPE, () => { expect(Date.now() - start).toBeLessThan(1000); expect((getMaskedField() as HTMLInputElement).value.length).toBeLessThanOrEqual( - RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + RegexHelper.MAX_MATCHES_INPUT_LENGTH ); }); it("should reject an oversized programmatic value with a validation error when maskRegex is set but no explicit max/length rule governs the length", async () => { - const oversizedValue = "a".repeat(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + 1); + const oversizedValue = "a".repeat(RegexHelper.MAX_MATCHES_INPUT_LENGTH + 1); renderComponent( { maskRange: null, maskRegex: "/^(hello)/g" }, { defaultValues: { [COMPONENT_ID]: oversizedValue } } @@ -152,14 +152,14 @@ describe(UI_TYPE, () => { expect( getErrorMessage( false, - ERROR_MESSAGES.MASKED_FIELD.VALUE_TOO_LONG(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH) + ERROR_MESSAGES.MASKED_FIELD.VALUE_TOO_LONG(RegexHelper.MAX_MATCHES_INPUT_LENGTH) ) ).toBeInTheDocument(); expect(SUBMIT_FN).not.toHaveBeenCalled(); }); it("should not reject an oversized value when an explicit max validation rule already permits that length", async () => { - const value = "a".repeat(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + 1); + const value = "a".repeat(RegexHelper.MAX_MATCHES_INPUT_LENGTH + 1); renderComponent( { maskRange: null, maskRegex: "/^(hello)/g", validation: [{ max: 1000 }] }, { defaultValues: { [COMPONENT_ID]: value } } diff --git a/src/__tests__/utils/regex-helper.spec.ts b/src/__tests__/utils/regex-helper.spec.ts index 344aaed49..d7eeafb63 100644 --- a/src/__tests__/utils/regex-helper.spec.ts +++ b/src/__tests__/utils/regex-helper.spec.ts @@ -33,7 +33,7 @@ describe("regex-helper", () => { }); it("should not hang and should return false when value exceeds the safe length bound", () => { - const maliciousValue = `${"a".repeat(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + 1)}!`; + const maliciousValue = `${"a".repeat(RegexHelper.MAX_MATCHES_INPUT_LENGTH + 1)}!`; const start = Date.now(); expect(RegexHelper.safeTestRegex(/^(a+)+$/, maliciousValue)).toBe(false); diff --git a/src/components/fields/masked-field/masked-field.tsx b/src/components/fields/masked-field/masked-field.tsx index 53d3b9742..3bf82182d 100644 --- a/src/components/fields/masked-field/masked-field.tsx +++ b/src/components/fields/masked-field/masked-field.tsx @@ -30,7 +30,7 @@ export const MaskedField = (props: IGenericFieldProps) => { const lengthRule = validation?.find((rule) => "length" in rule); if (maxRule?.max > 0) return maxRule.max; if (lengthRule?.length > 0) return lengthRule.length; - return RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH; + return RegexHelper.MAX_MATCHES_INPUT_LENGTH; }; const safeLength = getMaskRegexSafeLength(); @@ -56,8 +56,8 @@ export const MaskedField = (props: IGenericFieldProps) => { let schema = Yup.string(); if (maskRegex && !maxRule && !lengthRule) { schema = schema.max( - RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH, - ERROR_MESSAGES.MASKED_FIELD.VALUE_TOO_LONG(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH) + RegexHelper.MAX_MATCHES_INPUT_LENGTH, + ERROR_MESSAGES.MASKED_FIELD.VALUE_TOO_LONG(RegexHelper.MAX_MATCHES_INPUT_LENGTH) ); } setFieldValidationConfig(id, schema, validation); @@ -68,7 +68,7 @@ export const MaskedField = (props: IGenericFieldProps) => { } else if (lengthRule?.length > 0) { attributes.maxLength = lengthRule.length; } else if (maskRegex) { - attributes.maxLength = RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH; + attributes.maxLength = RegexHelper.MAX_MATCHES_INPUT_LENGTH; } setDerivedAttributes(attributes); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/context-providers/yup/custom-conditions/index.ts b/src/context-providers/yup/custom-conditions/index.ts index 3f6d2e23a..2c7bc0ff2 100644 --- a/src/context-providers/yup/custom-conditions/index.ts +++ b/src/context-providers/yup/custom-conditions/index.ts @@ -28,7 +28,7 @@ YupHelper.addCondition("string", "notMatches", (value: string, regex: string) => console.warn(`invalid regex pattern: ${regex}`); return true; } - if (value.length > RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH) { + if (value.length > RegexHelper.MAX_MATCHES_INPUT_LENGTH) { return false; } return !parsedRegex.test(value); diff --git a/src/utils/regex-helper.ts b/src/utils/regex-helper.ts index 17d379a47..6431582cb 100644 --- a/src/utils/regex-helper.ts +++ b/src/utils/regex-helper.ts @@ -1,5 +1,5 @@ export namespace RegexHelper { - export const MAX_SAFE_PATTERN_INPUT_LENGTH = 500; + export const MAX_MATCHES_INPUT_LENGTH = 1000; export const compile = (pattern: string): RegExp | undefined => { try { @@ -12,7 +12,7 @@ export namespace RegexHelper { export const safeTestRegex = (regex: RegExp | undefined, value: string): boolean => { if (!regex) return false; - if (value.length > MAX_SAFE_PATTERN_INPUT_LENGTH) return false; + if (value.length > MAX_MATCHES_INPUT_LENGTH) return false; return regex.test(value); }; } From da72f13931da52958fafb6d1b68306d9c7bbd1d2 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:56 +0800 Subject: [PATCH 13/22] [MOL-22453][SX] yup matches: also pass empty string --- src/context-providers/yup/helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/context-providers/yup/helper.ts b/src/context-providers/yup/helper.ts index 43b0e9d6b..aa2e06ff0 100644 --- a/src/context-providers/yup/helper.ts +++ b/src/context-providers/yup/helper.ts @@ -199,7 +199,7 @@ export namespace YupHelper { name: "matches", message: rule.errorMessage, test: (value) => - value === undefined || value === null || RegexHelper.safeTestRegex(regex, value), + value === undefined || value === null || value === "" || RegexHelper.safeTestRegex(regex, value), }); } else { console.warn(`error applying "${ruleKey}" condition to ${yupSchema.type} schema`); From 93025b29c5341d8f4575dbb7181d8ffec5a2f9c0 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:40:31 +0800 Subject: [PATCH 14/22] [MOL-22453][SX] LocationField: use vm.runInNewContext to prevent CI hang on ReDoS regression --- .../location-modal/location-search/helper.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts b/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts index a5cdfd0bc..66c4b1f86 100644 --- a/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts +++ b/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts @@ -1,3 +1,4 @@ +import vm from "vm"; import { boldResultsWithQuery } from "../../../../../../components/fields/location-field/location-modal/location-search/helper"; import { IResultListItem } from "../../../../../../components/fields/location-field/types"; @@ -20,10 +21,9 @@ describe("boldResultsWithQuery", () => { }); it("should complete within a reasonable time for any query string", () => { - const start = Date.now(); + const input = [buildResult("a".repeat(25) + "!")]; expect(() => - boldResultsWithQuery([buildResult("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!")], "(a+)+$") + vm.runInNewContext("fn(input, query)", { fn: boldResultsWithQuery, input, query: "(a+)+$" }, { timeout: 1000 }) ).not.toThrow(); - expect(Date.now() - start).toBeLessThan(1000); }); }); From 6685bec9844d55ed09718d1e98293f52822e6ce3 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 10:15:16 +0800 Subject: [PATCH 15/22] [MOL-22453][SX] Stories: document url()/import stripping in locationModalStyles and imageReviewModalStyles --- .../image-upload/image-upload.stories.tsx | 18 +++++-- .../location-field/location-field.stories.tsx | 52 ++++++++++++++++--- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/stories/3-fields/image-upload/image-upload.stories.tsx b/src/stories/3-fields/image-upload/image-upload.stories.tsx index 3ba2ad1ba..385e6d0ef 100644 --- a/src/stories/3-fields/image-upload/image-upload.stories.tsx +++ b/src/stories/3-fields/image-upload/image-upload.stories.tsx @@ -1,6 +1,6 @@ -import { action } from "@storybook/addon-actions"; -import { ArgTypes, Stories, Title } from "@storybook/addon-docs"; -import { Meta, StoryFn } from "@storybook/react"; +import { action } from "storybook/actions"; +import { ArgTypes, Stories, Title } from "@storybook/addon-docs/blocks"; +import { Meta, StoryFn } from "@storybook/react-webpack5"; import { useEffect, useRef } from "react"; import { IImageUploadSchema } from "../../../components/fields"; import { IFrontendEngineRef } from "../../../components/frontend-engine"; @@ -189,6 +189,17 @@ const meta: Meta = { defaultValue: { summary: null }, }, }, + imageReviewModalStyles: { + description: "CSS string applied directly to the image review modal box via `style.cssText`. Note: `url()` and `@import` are stripped before application.", + table: { + type: { + summary: "string", + }, + }, + control: { + type: "text", + }, + }, }, }; export default meta; @@ -397,6 +408,7 @@ export const WithTooltip: StoryFn = (args: IImageUploadSchem const formRef = useRef(); const handleTooltipClick = (e: unknown) => action("click-tooltip")(e); useEffect(() => { + if (!formRef.current) return; const currentFormRef = formRef.current; currentFormRef.addFieldEventListener("image-upload", "click-tooltip", id, handleTooltipClick); return () => currentFormRef.removeFieldEventListener("image-upload", "click-tooltip", id, handleTooltipClick); diff --git a/src/stories/3-fields/location-field/location-field.stories.tsx b/src/stories/3-fields/location-field/location-field.stories.tsx index a9ad472fd..433b98f3f 100644 --- a/src/stories/3-fields/location-field/location-field.stories.tsx +++ b/src/stories/3-fields/location-field/location-field.stories.tsx @@ -1,5 +1,5 @@ -import { ArgTypes, Stories, Title } from "@storybook/addon-docs"; -import { Meta, StoryFn } from "@storybook/react"; +import { ArgTypes, Stories, Title } from "@storybook/addon-docs/blocks"; +import { Meta, StoryFn } from "@storybook/react-webpack5"; import { useEffect, useRef } from "react"; import { ILocationCoord, ILocationFieldSchema, ILocationFieldValues } from "../../../components/fields"; import { IMapPin } from "../../../components/fields/location-field/location-modal/location-picker/types"; @@ -16,18 +16,19 @@ import { const recaptchaSiteKey = "6LfCjocsAAAAALM6wuZN3bqarbgbdaLuJIgFSrXT"; -const reverseGeocode = "https://api.dev.lifesg.io/onemap/revgeocode"; -const convertLatLngToXY = "https://api.dev.lifesg.io/onemap/4326to3414"; -const search = "https://api.dev.lifesg.io/onemap/search"; +const reverseGeocode = "https://api.dev.life.gov.sg/onemap/revgeocode"; +const convertLatLngToXY = "https://api.dev.life.gov.sg/onemap/4326to3414"; +const search = "https://api.dev.life.gov.sg/onemap/search"; const defaultMapApi = { reverseGeocode, convertLatLngToXY, search, headers: { - "x-client-app": "LifeSG", + "x-client-app": "LIFESG", }, }; + const meta: Meta = { title: "Field/LocationField", parameters: { @@ -185,6 +186,31 @@ const meta: Meta = { type: "object", }, }, + locationModalStyles: { + description: “CSS string applied directly to the location modal box via `style.cssText`. Note: `url()` and `@import` are stripped before application.”, + table: { + type: { + summary: “string”, + }, + }, + control: { + type: “text”, + }, + }, + restrictNonSGLocation: { + description: + “Prevents confirming and submitting locations that are outside Singapore. On confirming any selected location — a searched address (e.g. `CAUSEWAY (JOHOR)`), a map selection or an unresolvable `Pin location: , ` value — its coordinates are checked against the coastal outlines of SLA's National Map Polygon dataset: if it falls on a neighbouring (JOHOR (MALAYSIA)) landmass or in waters outside Singapore, the “This location is outside Singapore.” prompt is shown and the location modal stays open. Areas within Singapore that simply have no addresses nearby (e.g. sea just off the coast, reservoirs) remain confirmable. Prefilled values that resolve to locations outside Singapore are cleared and such values fail validation on submission.”, + table: { + type: { + summary: "boolean", + }, + defaultValue: { summary: "false" }, + }, + options: [true, false], + control: { + type: "boolean", + }, + }, }, }; export default meta; @@ -298,6 +324,19 @@ MustHavePostalCode.args = { mapApi: defaultMapApi, }; +export const RestrictNonSGLocation = DefaultStoryTemplate( + "location-field-restrict-non-sg-location", + false, + recaptchaSiteKey +).bind({}); +RestrictNonSGLocation.args = { + uiType: "location-field", + label: "RestrictNonSGLocation", + restrictNonSGLocation: true, + validation: [{ required: true }], + mapApi: defaultMapApi, +}; + export const Warning = WarningStoryTemplate("location-field-with-warning", recaptchaSiteKey).bind( {} ); @@ -413,6 +452,7 @@ const IndicateCurrentLocationTemplate = () => const formRef = useRef(); useEffect(() => { + if (!formRef.current) return; const currentFormRef = formRef.current; currentFormRef.addFieldEventListener("location-field", "get-selectable-pins", id, getPins); From 130e38c56b2292bfa0e5809bcc6dcf1ec949f2da Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 15:56:14 +0800 Subject: [PATCH 16/22] [MOL-22453][SX] Stories: fix curly quotes (U+201C/U+201D) in locationModalStyles argType --- .../3-fields/location-field/location-field.stories.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/stories/3-fields/location-field/location-field.stories.tsx b/src/stories/3-fields/location-field/location-field.stories.tsx index 433b98f3f..f0bf0c28a 100644 --- a/src/stories/3-fields/location-field/location-field.stories.tsx +++ b/src/stories/3-fields/location-field/location-field.stories.tsx @@ -187,19 +187,19 @@ const meta: Meta = { }, }, locationModalStyles: { - description: “CSS string applied directly to the location modal box via `style.cssText`. Note: `url()` and `@import` are stripped before application.”, + description: "CSS string applied directly to the location modal box via `style.cssText`. Note: `url()` and `@import` are stripped before application.", table: { type: { - summary: “string”, + summary: "string", }, }, control: { - type: “text”, + type: "text", }, }, restrictNonSGLocation: { description: - “Prevents confirming and submitting locations that are outside Singapore. On confirming any selected location — a searched address (e.g. `CAUSEWAY (JOHOR)`), a map selection or an unresolvable `Pin location: , ` value — its coordinates are checked against the coastal outlines of SLA's National Map Polygon dataset: if it falls on a neighbouring (JOHOR (MALAYSIA)) landmass or in waters outside Singapore, the “This location is outside Singapore.” prompt is shown and the location modal stays open. Areas within Singapore that simply have no addresses nearby (e.g. sea just off the coast, reservoirs) remain confirmable. Prefilled values that resolve to locations outside Singapore are cleared and such values fail validation on submission.”, + "Prevents confirming and submitting locations that are outside Singapore. On confirming any selected location — a searched address (e.g. `CAUSEWAY (JOHOR)`), a map selection or an unresolvable `Pin location: , ` value — its coordinates are checked against the coastal outlines of SLA's National Map Polygon dataset: if it falls on a neighbouring (JOHOR (MALAYSIA)) landmass or in waters outside Singapore, the “This location is outside Singapore.” prompt is shown and the location modal stays open. Areas within Singapore that simply have no addresses nearby (e.g. sea just off the coast, reservoirs) remain confirmable. Prefilled values that resolve to locations outside Singapore are cleared and such values fail validation on submission.", table: { type: { summary: "boolean", From 7d661564ec7565a14ff0247c7c4b39246a2d363a Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 16:30:01 +0800 Subject: [PATCH 17/22] [MOL-22453][SX] LocationField: increase ReDoS test input to n=30 for reliable timeout on fast hardware --- .../location-modal/location-search/helper.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts b/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts index 66c4b1f86..2e701d1d1 100644 --- a/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts +++ b/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts @@ -21,7 +21,7 @@ describe("boldResultsWithQuery", () => { }); it("should complete within a reasonable time for any query string", () => { - const input = [buildResult("a".repeat(25) + "!")]; + const input = [buildResult("a".repeat(30) + "!")]; expect(() => vm.runInNewContext("fn(input, query)", { fn: boldResultsWithQuery, input, query: "(a+)+$" }, { timeout: 1000 }) ).not.toThrow(); From 5586cd5d0b15a251b56b23fd91314cba11ef3525 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 16:47:46 +0800 Subject: [PATCH 18/22] [MOL-22453][SX] ImageUpload: use repeat(1000) to exceed MAX_MATCHES_INPUT_LENGTH guard in hang test --- .../fields/image-upload/image-upload.spec.tsx | 497 ++++++++---------- 1 file changed, 229 insertions(+), 268 deletions(-) diff --git a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx index c998d7cfa..b1dab377b 100644 --- a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx +++ b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx @@ -5,7 +5,7 @@ import { FrontendEngine } from "../../../../components"; import { EImageStatus, IImageUploadSchema } from "../../../../components/fields"; import { ERROR_MESSAGES } from "../../../../components/shared"; import { IFrontendEngineData, IFrontendEngineProps, IFrontendEngineRef } from "../../../../components/types"; -import { AxiosApiClient, FileHelper, ImageHelper, WindowHelper } from "../../../../utils"; +import { AxiosApiClient, FileHelper, ImageHelper } from "../../../../utils"; import * as IdHelper from "../../../../utils/id-helper"; import { ERROR_MESSAGE, @@ -20,6 +20,8 @@ import { getSubmitButton, getSubmitButtonProps, } from "../../../common"; +import * as WindowHelper from "../../../../utils/hooks/use-window-helper"; +import { dirtyStateTestSuite } from "../../../common/tests"; const METADATA = { dateTimeOriginal: "2009:10:10 04:09:20", lat: 22.316033333333333, lng: 114.17031666666666 }; @@ -33,6 +35,11 @@ const FILE_2 = new File(["file"], "test2.jpg", { }); const COMPONENT_ID = "field"; const UI_TYPE = "image-upload"; +const DELETE_PROMPT_TEXT = "Delete photo?"; +const DELETE_EXIT_PROMPT_TEXT = "Delete photo and exit?"; +const REVIEW_MODAL_TEXT = "Review photos"; +const REVIEW_PROMPT_TEXT = "Review photos?"; +const REVIEW_EXIT_PROMPT_TEXT = "Exit without saving?"; const SUBMIT_FN = jest.fn(); let uploadSpy: jest.SpyInstance; let extractMetadataSpy: jest.SpyInstance; @@ -41,6 +48,8 @@ const getSaveButton = (isQuery = false): HTMLElement => getField("button", "Save const getDragInputUploadField = (): HTMLElement => screen.getByTestId("field-drag-upload__hidden-input"); const getReviewModalUploadField = (): HTMLElement => screen.getByTestId("field-image-thumbnails__file-input"); +const waitForUpload = async () => await new Promise((resolve) => setTimeout(resolve, 100)); + interface ICustomFrontendEngineProps extends IFrontendEngineProps { eventType: string; eventListener: (this: Element, ev: Event) => any; @@ -141,7 +150,7 @@ const renderComponent = async (options: IRenderAndPerformActionsOptions = {}) => }, }); if (uploadType === "input") { - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForUpload(); await flushPromise(); } else { await flushPromise(); @@ -150,8 +159,9 @@ const renderComponent = async (options: IRenderAndPerformActionsOptions = {}) => }); if (reviewImage) { - await waitFor(() => fireEvent.click(getField("button", "Ok"))); - await new Promise((resolve) => setTimeout(resolve)); + await waitFor(() => expect(screen.getByText(REVIEW_PROMPT_TEXT)).toBeVisible()); + fireEvent.click(getField("button", "Ok")); + await flushPromise(); } }; @@ -287,7 +297,7 @@ describe("image-upload", () => { }); await waitFor(() => fireEvent.click(getSubmitButton())); - expect(SUBMIT_FN).not.toBeCalled(); + expect(SUBMIT_FN).not.toHaveBeenCalled(); expect(screen.getByText(ERROR_MESSAGE)).toBeInTheDocument(); }); @@ -325,7 +335,7 @@ describe("image-upload", () => { }); await waitFor(() => expect(screen.getByText(ERROR_MESSAGE)).toBeInTheDocument()); - expect(uploadSpy).not.toBeCalled(); + expect(uploadSpy).not.toHaveBeenCalled(); }); it("should not show error when filename matches the pattern", async () => { @@ -336,7 +346,7 @@ describe("image-upload", () => { }); expect(screen.queryByText(ERROR_MESSAGE)).not.toBeInTheDocument(); - await waitFor(() => expect(uploadSpy).toBeCalledTimes(1)); + await waitFor(() => expect(uploadSpy).toHaveBeenCalledTimes(1)); }); it("should be able to submit a valid file when a matches rule is configured", async () => { @@ -346,7 +356,7 @@ describe("image-upload", () => { uploadType: "input", }); - await waitFor(() => expect(uploadSpy).toBeCalledTimes(1)); + await waitFor(() => expect(uploadSpy).toHaveBeenCalledTimes(1)); await waitFor(() => fireEvent.click(getSubmitButton())); expect(SUBMIT_FN).toHaveBeenCalledWith( expect.objectContaining({ @@ -378,7 +388,7 @@ describe("image-upload", () => { uploadType: "input", }); - await waitFor(() => expect(uploadSpy).toBeCalledTimes(1)); + await waitFor(() => expect(uploadSpy).toHaveBeenCalledTimes(1)); await waitFor(() => fireEvent.click(getSubmitButton())); expect(SUBMIT_FN).toHaveBeenCalledWith( expect.objectContaining({ @@ -388,7 +398,7 @@ describe("image-upload", () => { }); it("should not hang when matching a long filename against a regex pattern", async () => { - const maliciousFile = new File(["file"], `${"a".repeat(600)}!.jpg`, { type: "image/jpeg" }); + const maliciousFile = new File(["file"], `${"a".repeat(1000)}!.jpg`, { type: "image/jpeg" }); const start = Date.now(); await renderComponent({ @@ -399,7 +409,7 @@ describe("image-upload", () => { await waitFor(() => expect(screen.getByText(ERROR_MESSAGE)).toBeInTheDocument()); expect(Date.now() - start).toBeLessThan(1000); - expect(uploadSpy).not.toBeCalled(); + expect(uploadSpy).not.toHaveBeenCalled(); }); }); }); @@ -421,7 +431,7 @@ describe("image-upload", () => { it("should show and upload as many images", async () => { expect(screen.getByText(FILE_1.name)).toBeInTheDocument(); expect(screen.getByText(FILE_2.name)).toBeInTheDocument(); - expect(uploadSpy).toBeCalledTimes(2); + expect(uploadSpy).toHaveBeenCalledTimes(2); }); it("should hide the add button", () => { @@ -460,7 +470,7 @@ describe("image-upload", () => { it("should show and upload up to max number of images", async () => { expect(screen.getByText(FILE_1.name)).toBeInTheDocument(); expect(screen.queryByText(FILE_2.name)).not.toBeInTheDocument(); - expect(uploadSpy).toBeCalledTimes(1); + expect(uploadSpy).toHaveBeenCalledTimes(1); }); it("should display error message when adding beyond max no. of images", () => { @@ -502,7 +512,7 @@ describe("image-upload", () => { it("should not upload the invalid file and show an error message", () => { expect(screen.getByText(ERROR_MESSAGE)).toBeInTheDocument(); - expect(uploadSpy).toBeCalledTimes(1); + expect(uploadSpy).toHaveBeenCalledTimes(1); }); it("should submit only the valid files", async () => { @@ -531,7 +541,7 @@ describe("image-upload", () => { it("should not upload the erroneous file and show an error message", async () => { expect(screen.getByText(ERROR_MESSAGES.UPLOAD().GENERIC)).toBeInTheDocument(); - expect(uploadSpy).toBeCalledTimes(1); + expect(uploadSpy).toHaveBeenCalledTimes(1); }); it("should submit only the valid files", async () => { @@ -566,7 +576,7 @@ describe("image-upload", () => { it("should show error and not upload the image that exceeds the file size limit", async () => { expect(screen.getByText(ERROR_MESSAGE)).toBeInTheDocument(); - expect(uploadSpy).toBeCalledTimes(1); + expect(uploadSpy).toHaveBeenCalledTimes(1); }); it("should submit only the valid files", async () => { @@ -597,34 +607,37 @@ describe("image-upload", () => { files: [FILE_1], uploadType: inputType, }); - await flushPromise(); + await act(async () => { + await flushPromise(); + }); - expect(compressSpy).not.toBeCalled(); + expect(compressSpy).not.toHaveBeenCalled(); }); it("should compress image if compress=true and max size is defined", async () => { const compressSpy = jest.spyOn(ImageHelper, "compressImage"); + await renderComponent({ + files: [FILE_1], + overrideField: { compress: true, validation: [{ maxSizeInKb: 1 }] }, + uploadType: inputType, + }); await act(async () => { - await renderComponent({ - files: [FILE_1], - overrideField: { compress: true, validation: [{ maxSizeInKb: 1 }] }, - uploadType: inputType, - }); await flushPromise(); }); - expect(compressSpy).toBeCalled(); + expect(compressSpy).toHaveBeenCalled(); }); it("Should extract image metadata", async () => { jest.spyOn(ImageHelper, "compressImage").mockResolvedValue(FILE_1); - await waitFor(async () => { - await renderComponent({ - files: [FILE_1], - overrideField: { compress: true, validation: [{ maxSizeInKb: 1 }] }, - uploadType: inputType, - }); + await renderComponent({ + files: [FILE_1], + overrideField: { compress: true, validation: [{ maxSizeInKb: 1 }] }, + uploadType: inputType, + }); + await act(async () => { + await flushPromise(); }); await waitFor(() => expect(extractMetadataSpy).toHaveBeenCalledTimes(1)); @@ -633,16 +646,16 @@ describe("image-upload", () => { it("should resize image to fit dimensions when crop is false", async () => { const resampleSpy = jest.spyOn(ImageHelper, "resampleImage"); + await renderComponent({ + files: [FILE_1], + overrideField: { + compress: true, + crop: false, + dimensions: { width: 500, height: 500 }, + }, + uploadType: inputType, + }); await act(async () => { - await renderComponent({ - files: [FILE_1], - overrideField: { - compress: true, - crop: false, - dimensions: { width: 500, height: 500 }, - }, - uploadType: inputType, - }); await flushPromise(); }); @@ -652,16 +665,16 @@ describe("image-upload", () => { it("should crop image to exact dimensions when crop is true", async () => { const resampleSpy = jest.spyOn(ImageHelper, "resampleImage"); + await renderComponent({ + files: [FILE_1], + overrideField: { + compress: true, + crop: true, + dimensions: { width: 500, height: 500 }, + }, + uploadType: inputType, + }); await act(async () => { - await renderComponent({ - files: [FILE_1], - overrideField: { - compress: true, - crop: true, - dimensions: { width: 500, height: 500 }, - }, - uploadType: inputType, - }); await flushPromise(); }); @@ -676,16 +689,16 @@ describe("image-upload", () => { it("should not use crop when compress is false even if crop is true", async () => { const resampleSpy = jest.spyOn(ImageHelper, "resampleImage"); const convertSpy = jest.spyOn(ImageHelper, "convertBlob"); + await renderComponent({ + files: [FILE_1], + overrideField: { + compress: false, + crop: true, + dimensions: { width: 500, height: 500 }, + }, + uploadType: inputType, + }); await act(async () => { - await renderComponent({ - files: [FILE_1], - overrideField: { - compress: false, - crop: true, - dimensions: { width: 500, height: 500 }, - }, - uploadType: inputType, - }); await flushPromise(); }); @@ -703,26 +716,31 @@ describe("image-upload", () => { files: [FILE_1], overrideField: { editImage: true }, }); + await waitFor(() => expect(screen.getByText(REVIEW_PROMPT_TEXT)).toBeVisible()); }); it("should not upload photo", () => { - expect(uploadSpy).not.toBeCalled(); + expect(uploadSpy).not.toHaveBeenCalled(); }); - it("should show confirmation prompt", () => { - expect(screen.getByText("Review photos?")).toBeVisible(); + it("should show confirmation prompt", async () => { + expect(await screen.findByText(REVIEW_PROMPT_TEXT)).toBeVisible(); }); it("should show review modal after clicking ok in confirmation prompt", async () => { - await waitFor(() => fireEvent.click(getField("button", "Ok"))); + await waitFor(() => { + expect(getField("button", "Ok")).toBeVisible(); + }); + + fireEvent.click(getField("button", "Ok")); - expect(screen.getByText("Review photos")).toBeVisible(); + expect(await screen.findByText(REVIEW_MODAL_TEXT)).toBeVisible(); }); }); describe("mobile", () => { beforeEach(async () => { - jest.spyOn(WindowHelper, "isMobileView").mockReturnValue(true); + jest.spyOn(WindowHelper, "useWindowHelper").mockReturnValue(() => true); await renderComponent({ files: [FILE_1], @@ -731,12 +749,14 @@ describe("image-upload", () => { }); it("should not upload photo", () => { - expect(uploadSpy).not.toBeCalled(); + expect(uploadSpy).not.toHaveBeenCalled(); }); it("should skip confirmation prompt and show review modal", async () => { - expect(screen.getByText("Review photos?")).not.toBeVisible(); - expect(screen.getByText("Review photos")).toBeVisible(); + await waitFor(() => { + expect(screen.getByText(REVIEW_MODAL_TEXT)).toBeVisible(); + }); + expect(screen.queryByText(REVIEW_PROMPT_TEXT)).not.toBeInTheDocument(); }); }); }); @@ -752,11 +772,13 @@ describe("image-upload", () => { }); it("should not upload images", () => { - expect(uploadSpy).not.toBeCalled(); + expect(uploadSpy).not.toHaveBeenCalled(); }); - it("should show as many images", () => { - expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); + it("should show as many images", async () => { + await waitFor(() => { + expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); + }); expect(getField("button", `thumbnail of ${FILE_2.name}`)).toBeInTheDocument(); }); @@ -765,10 +787,15 @@ describe("image-upload", () => { }); it("should upload as many images after clicking save", async () => { - await waitFor(() => fireEvent.click(getSaveButton())); - await flushPromise(); + await waitFor(() => { + expect(getSaveButton()).toBeEnabled(); + }); + await act(async () => { + fireEvent.click(getSaveButton()); + await flushPromise(); + }); - expect(uploadSpy).toBeCalledTimes(2); + expect(uploadSpy).toHaveBeenCalledTimes(2); }); }); @@ -785,14 +812,18 @@ describe("image-upload", () => { }); jest.spyOn(FileHelper, "getType").mockResolvedValueOnce({ ext: "png", mime: "image/png" }); - await waitFor(() => fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } })); + fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } }); await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload + await waitForUpload(); }); - expect(getField("button", `error with ${FILE_1.name}`)).toBeInTheDocument(); + await waitFor(() => { + expect(getField("button", `error with ${FILE_1.name}`)).toBeInTheDocument(); + }); expect(screen.getByText(ERROR_MESSAGES.UPLOAD("photo").MODAL.FILE_TYPE.TITLE)).toBeInTheDocument(); - expect(getSaveButton()).toBeDisabled(); + await waitFor(() => { + expect(getSaveButton()).toBeDisabled(); + }); }); }); @@ -809,13 +840,15 @@ describe("image-upload", () => { }); jest.spyOn(ImageHelper, "convertBlob").mockRejectedValue("error"); - await waitFor(() => fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } })); + fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } }); await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload + await waitForUpload(); }); expect(screen.getByText(ERROR_MESSAGES.UPLOAD("photo").MODAL.GENERIC_ERROR.TITLE)).toBeInTheDocument(); - expect(getSaveButton()).toBeDisabled(); + await waitFor(() => { + expect(getSaveButton()).toBeDisabled(); + }); }); }); @@ -830,19 +863,21 @@ describe("image-upload", () => { }); jest.spyOn(ImageHelper, "convertBlob").mockResolvedValue(`${JPG_BASE64}${JPG_BASE64}`); - await waitFor(() => fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } })); + fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } }); await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload + await waitForUpload(); }); }); it("should not compress the image", async () => { - expect(compressSpy).not.toBeCalled(); + expect(compressSpy).not.toHaveBeenCalled(); }); it("should show error and disable submit button if image exceeds max size", async () => { expect(screen.getByText(ERROR_MESSAGES.UPLOAD("photo").MODAL.MAX_FILE_SIZE.TITLE)).toBeInTheDocument(); - expect(getSaveButton()).toBeDisabled(); + await waitFor(() => { + expect(getSaveButton()).toBeDisabled(); + }); }); it("Should extract image metadata", async () => { @@ -858,7 +893,8 @@ describe("image-upload", () => { overrideField: { editImage: true }, reviewImage: true, }); - await waitFor(() => fireEvent.click(getField("button", "Draw"))); + const drawButton = await screen.findByRole("button", { name: "Draw" }); + fireEvent.click(drawButton); }); it("should hide the thumbnails and show the drawing toolbar", () => { @@ -876,14 +912,13 @@ describe("image-upload", () => { jest.spyOn(ImageHelper, "dataUrlToImage").mockResolvedValue(new Image()); jest.spyOn(ImageHelper, "resampleImage").mockResolvedValue(FILE_1); - await act(async () => { - await waitFor(() => fireEvent.click(getField("button", "Save"))); - await waitFor(() => getField("button", `thumbnail of ${FILE_1.name}`)); - }); + fireEvent.click(getField("button", "Save")); - expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); - expect(getField("button", "eraser", true)).not.toBeInTheDocument(); - expect(getField("button", /brush$/i, true)).not.toBeInTheDocument(); + await waitFor(() => { + expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); + expect(getField("button", "eraser", true)).not.toBeInTheDocument(); + expect(getField("button", /brush$/i, true)).not.toBeInTheDocument(); + }); }); }); @@ -905,10 +940,14 @@ describe("image-upload", () => { reviewImage: true, }); - await waitFor(() => fireEvent.click(getField("button", "Draw"))); + await waitFor(() => { + expect(getField("button", "Draw")).toBeInTheDocument(); + }); + expect(getField("button", "Save")).toBeInTheDocument(); await act(async () => { - await waitFor(() => fireEvent.click(getField("button", "Save"))); + fireEvent.click(getField("button", "Draw")); + fireEvent.click(getField("button", "Save")); await flushPromise(); }); @@ -930,27 +969,36 @@ describe("image-upload", () => { overrideField: { editImage: true }, reviewImage: true, }); - await waitFor(() => fireEvent.click(getField("button", "Delete"))); + await waitFor(() => { + fireEvent.click(getField("button", "Delete")); + }); + await waitFor(() => { + expect(screen.getByText(DELETE_PROMPT_TEXT)).toBeVisible(); + }); }); - it("should show delete confirmation prompt on clicking the delete button", () => { - expect(screen.getByText("Delete photo?")).toBeVisible(); + it("should show delete confirmation prompt on clicking the delete button", async () => { + expect(await screen.findByText(DELETE_PROMPT_TEXT)).toBeVisible(); expect(getField("button", "Cancel")).toBeVisible(); expect(getField("button", "Yes, delete")).toBeVisible(); }); it("should delete the image and hide the prompt on confirming delete", async () => { - await waitFor(() => fireEvent.click(getField("button", "Yes, delete"))); + fireEvent.click(getField("button", "Yes, delete")); - expect(screen.getAllByRole("button", { name: /^thumbnail/i })).toHaveLength(2); - expect(screen.getByText("Delete photo?")).not.toBeVisible(); + await waitFor(() => expect(screen.getByText(DELETE_PROMPT_TEXT)).not.toBeVisible()); + await waitFor(() => { + expect(screen.getAllByRole("button", { name: /^thumbnail/i })).toHaveLength(2); + }); }); it("should not delete the image but dismiss the prompt on cancelling the confirmation prompt", async () => { - await waitFor(() => fireEvent.click(getField("button", "Cancel"))); + fireEvent.click(getField("button", "Cancel")); - expect(screen.getByText("Delete photo?")).not.toBeVisible(); - expect(screen.getAllByRole("button", { name: /^thumbnail/i })).toHaveLength(3); + await waitFor(() => expect(screen.getByText(DELETE_PROMPT_TEXT)).not.toBeVisible()); + await waitFor(() => { + expect(screen.getAllByRole("button", { name: /^thumbnail/i })).toHaveLength(3); + }); }); }); @@ -961,29 +1009,36 @@ describe("image-upload", () => { overrideField: { editImage: true }, reviewImage: true, }); - await waitFor(() => fireEvent.click(getField("button", "Delete"))); + await waitFor(() => { + fireEvent.click(getField("button", "Delete")); + }); + await waitFor(() => expect(screen.getByText(DELETE_EXIT_PROMPT_TEXT)).toBeVisible()); }); - it("should show delete and exit confirmation prompt on attempting to delete the last photo", () => { - expect(screen.getByText("Delete photo and exit?")).toBeVisible(); + it("should show delete and exit confirmation prompt on attempting to delete the last photo", async () => { + expect(await screen.findByText(DELETE_EXIT_PROMPT_TEXT)).toBeVisible(); expect(getField("button", "Cancel")).toBeVisible(); expect(getField("button", "Delete and exit")).toBeVisible(); }); it("should delete the image and close the review modal on deleting the last image", async () => { - await waitFor(() => fireEvent.click(getField("button", "Delete and exit"))); + fireEvent.click(getField("button", "Delete and exit")); - expect(getField("button", /^thumbnail/i, true)).not.toBeInTheDocument(); - expect(screen.queryByText("Delete photo and exit?")).not.toBeInTheDocument(); - expect(screen.queryByText("Review photos")).not.toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText(DELETE_EXIT_PROMPT_TEXT)).not.toBeInTheDocument()); + expect(screen.queryByText(REVIEW_MODAL_TEXT)).not.toBeInTheDocument(); + await waitFor(() => { + expect(getField("button", /^thumbnail/i, true)).not.toBeInTheDocument(); + }); }); it("should not delete the image and return to the review modal on cancelling the confirmation prompt", async () => { - await waitFor(() => fireEvent.click(getField("button", "Cancel"))); + fireEvent.click(getField("button", "Cancel")); - expect(getField("button", /^thumbnail/i)).toBeInTheDocument(); - expect(screen.getByText("Delete photo and exit?")).not.toBeVisible(); - expect(screen.getByText("Review photos")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(DELETE_EXIT_PROMPT_TEXT)).not.toBeVisible()); + expect(screen.getByText(REVIEW_MODAL_TEXT)).toBeInTheDocument(); + await waitFor(() => { + expect(getField("button", /^thumbnail/i)).toBeInTheDocument(); + }); }); }); }); @@ -995,29 +1050,39 @@ describe("image-upload", () => { overrideField: { editImage: true }, reviewImage: true, }); - await waitFor(() => fireEvent.click(getField("button", "exit review modal"))); + await waitFor(() => { + expect(screen.getByText(REVIEW_MODAL_TEXT)).toBeVisible(); + }); + + await waitFor(() => { + fireEvent.click(getField("button", "exit review modal")); + }); + + await waitFor(() => expect(screen.getByText(REVIEW_EXIT_PROMPT_TEXT)).toBeVisible()); }); - it("should show confirmation prompt", () => { - expect(screen.getByText("Exit without saving?")).toBeVisible(); + it("should show confirmation prompt", async () => { expect(screen.getByText("Yes, exit")).toBeVisible(); expect(getField("button", "Cancel")).toBeVisible(); }); it("should close review modal on confirmation", async () => { - await waitFor(() => fireEvent.click(getField("button", "Yes, exit"))); + fireEvent.click(getField("button", "Yes, exit")); - expect(screen.queryByText("Exit without saving?")).not.toBeInTheDocument(); - expect(screen.queryByText("Review photos")).not.toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText(REVIEW_EXIT_PROMPT_TEXT)).not.toBeInTheDocument()); + expect(screen.queryByText(REVIEW_MODAL_TEXT)).not.toBeInTheDocument(); expect(getField("button", /^thumbnail/i, true)).not.toBeInTheDocument(); }); it("should not close review modal on cancelling the confirmation prompt", async () => { - await waitFor(() => fireEvent.click(getField("button", "Cancel"))); + fireEvent.click(getField("button", "Cancel")); - expect(screen.getByText("Exit without saving?")).not.toBeVisible(); - expect(screen.getByText("Review photos")).toBeInTheDocument(); - expect(getField("button", /^thumbnail/i)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(REVIEW_EXIT_PROMPT_TEXT)).not.toBeVisible()); + expect(screen.getByText(REVIEW_MODAL_TEXT)).toBeInTheDocument(); + + await waitFor(() => { + expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); + }); }); }); }); @@ -1026,7 +1091,7 @@ describe("image-upload", () => { it("should fire mount event on mount", async () => { const handleMount = jest.fn(); await renderComponent({ eventType: "mount", eventListener: handleMount }); - expect(handleMount).toBeCalled(); + expect(handleMount).toHaveBeenCalled(); }); it("should fire show-review-modal event on showing review modal", async () => { @@ -1039,7 +1104,7 @@ describe("image-upload", () => { reviewImage: true, }); - expect(handleShowReviewModal).toBeCalled(); + expect(handleShowReviewModal).toHaveBeenCalled(); }); it("should fire hide-review-modal event on hiding review modal", async () => { @@ -1053,7 +1118,7 @@ describe("image-upload", () => { }); await waitFor(() => fireEvent.click(getSaveButton())); - expect(handleHideReviewModal).toBeCalled(); + expect(handleHideReviewModal).toHaveBeenCalled(); }); it("should fire file-dialog event on showing file-dialog", async () => { @@ -1066,7 +1131,7 @@ describe("image-upload", () => { await waitFor(() => fireEvent.click(getField("button", "Image Upload"))); }); - expect(handleFileDialog).toBeCalled(); + expect(handleFileDialog).toHaveBeenCalled(); }); it("should fire save-review-images event on clicking save button in review modal", async () => { @@ -1080,7 +1145,7 @@ describe("image-upload", () => { }); await waitFor(() => fireEvent.click(getSaveButton())); - expect(handleSaveImages).toBeCalled(); + expect(handleSaveImages).toHaveBeenCalled(); }); it("should not save images / close modal if save-review-images event is prevented", async () => { @@ -1097,7 +1162,7 @@ describe("image-upload", () => { await waitFor(() => fireEvent.click(getSaveButton())); expect(getSaveButton()).toBeInTheDocument(); - expect(uploadSpy).not.toBeCalled(); + expect(uploadSpy).not.toHaveBeenCalled(); }); it("should allow retry through save-review-images event detail", async () => { @@ -1119,9 +1184,9 @@ describe("image-upload", () => { }); await waitFor(() => fireEvent.click(getSaveButton())); - expect(mockCounter.value).toBeCalledTimes(2); + expect(mockCounter.value).toHaveBeenCalledTimes(2); expect(getSaveButton(true)).not.toBeInTheDocument(); - expect(uploadSpy).toBeCalled(); + expect(uploadSpy).toHaveBeenCalled(); }); it("should fire hide-review-modal event on hiding review modal", async () => { @@ -1135,7 +1200,7 @@ describe("image-upload", () => { }); await waitFor(() => fireEvent.click(getSaveButton())); - expect(handleHideReviewModal).toBeCalled(); + expect(handleHideReviewModal).toHaveBeenCalled(); }); it("should allow dismissing of the review modal via dismiss-review-modal event", async () => { @@ -1154,9 +1219,10 @@ describe("image-upload", () => { onClick: handleClick, }); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + const customButton = await screen.findByRole("button", { name: "Custom Button" }); + fireEvent.click(customButton); - expect(handleDismissReviewModal).toBeCalled(); + expect(handleDismissReviewModal).toHaveBeenCalled(); }); it("should be able to save review images via trigger-save-review-images event", async () => { @@ -1173,9 +1239,10 @@ describe("image-upload", () => { onClick: handleClick, }); - await waitFor(() => fireEvent.click(screen.getByRole("button", { name: "Custom Button" }))); + const customButton = await screen.findByRole("button", { name: "Custom Button" }); + fireEvent.click(customButton); - expect(saveReviewImageFn).toBeCalled(); + expect(saveReviewImageFn).toHaveBeenCalled(); }); it("should be able to show custom error message when update-image-status is fired", async () => { @@ -1195,7 +1262,8 @@ describe("image-upload", () => { onClick: handleClick, }); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + const customButton = await screen.findByRole("button", { name: "Custom Button" }); + fireEvent.click(customButton); const errMsg = screen.getAllByTestId("field-file-item-1__error-text")[0].innerHTML; expect(errMsg).toBe(ERROR_MESSAGE); expect(screen.getAllByTestId("field-file-item-1__error-text")[0]).toBeInTheDocument(); @@ -1306,12 +1374,8 @@ describe("image-upload", () => { }); }); - describe("dirty state", () => { - let formIsDirty: boolean; - const handleClick = (ref: React.MutableRefObject) => { - formIsDirty = ref.current.isDirty; - }; - const json: IFrontendEngineData = { + dirtyStateTestSuite({ + schema: { id: FRONTEND_ENGINE_ID, sections: { section: { @@ -1327,25 +1391,15 @@ describe("image-upload", () => { }, }, }, - }; - - beforeEach(() => { - formIsDirty = undefined; - jest.spyOn(ImageHelper, "convertBlob").mockResolvedValue(JPG_BASE64); - jest.spyOn(ImageHelper, "getMetadata").mockResolvedValue(METADATA); - jest.spyOn(FileHelper, "dataUrlToBlob").mockResolvedValue(FILE_1); - jest.spyOn(FileHelper, "getType").mockResolvedValue({ ext: "jpg", mime: "image/jpeg" }); - }); - - it("should mount without setting field state as dirty", () => { - render(); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); - - expect(formIsDirty).toBe(false); - }); - - it("should set form state as dirty if user adds an image", async () => { - render(); + }, + componentId: COMPONENT_ID, + defaultValue: [ + { + fileName: FILE_1.name, + dataURL: JPG_BASE64, + }, + ], + modifyField: async () => { await act(async () => { fireEvent.change(getDragInputUploadField(), { target: { @@ -1354,108 +1408,17 @@ describe("image-upload", () => { }); await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload }); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); - - expect(formIsDirty).toBe(true); - }); - - it("should support default value without setting form state as dirty", async () => { - render( - - ); - await act(async () => { - await flushPromise(); - }); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); - - expect(formIsDirty).toBe(false); - }); - - it("should set form state as dirty if user removes an image", async () => { - render( - - ); + }, + modifyAndRemoveField: async () => { await waitFor(() => fireEvent.click(screen.getByTestId(`${COMPONENT_ID}-file-item-1__btn-delete`))); await flushPromise(); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); - - expect(formIsDirty).toBe(true); - }); - - it("should reset and revert form dirty state to false", async () => { - render(); - await act(async () => { - fireEvent.change(getDragInputUploadField(), { - target: { - files: [FILE_1], - }, - }); - await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload - await flushPromise(100); - await waitFor(() => fireEvent.click(getResetButton())); - }); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); - - expect(formIsDirty).toBe(false); - }); - - it("should reset to default value without setting form state as dirty", async () => { - render( - - ); - await act(async () => { - fireEvent.change(getDragInputUploadField(), { - target: { - files: [FILE_2], - }, - }); - await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload - await flushPromise(100); - await waitFor(() => fireEvent.click(getResetButton())); - }); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); - - expect(formIsDirty).toBe(false); - }); + }, + beforeEach: () => { + jest.spyOn(ImageHelper, "convertBlob").mockResolvedValue(JPG_BASE64); + jest.spyOn(ImageHelper, "getMetadata").mockResolvedValue(METADATA); + jest.spyOn(FileHelper, "dataUrlToBlob").mockResolvedValue(FILE_1); + jest.spyOn(FileHelper, "getType").mockResolvedValue({ ext: "jpg", mime: "image/jpeg" }); + }, }); describe("when capture value is specified", () => { @@ -1513,10 +1476,9 @@ describe("image-upload", () => { await waitFor(() => fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1, FILE_2] } }) ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload + await waitFor(() => { + expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); }); - expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); expect(getField("button", `thumbnail of ${FILE_2.name}`)).toBeInTheDocument(); expect(getField("button", `thumbnail of test (1).jpg`)).toBeInTheDocument(); }); @@ -1530,10 +1492,9 @@ describe("image-upload", () => { await waitFor(() => fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1, FILE_2] } }) ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload + await waitFor(() => { + expect(getField("button", `error with ${FILE_1.name}`)).toBeInTheDocument(); }); - expect(getField("button", `error with ${FILE_1.name}`)).toBeInTheDocument(); expect( screen.getByText(ERROR_MESSAGES.UPLOAD("photo").MAX_FILES_WITH_REMAINING(1)) ).toBeInTheDocument(); From 1e58a5dff73ef7459d7c93411f5e56733918c167 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 17:19:07 +0800 Subject: [PATCH 19/22] [MOL-22453][SX] Tests: fix catastrophic-regex test values to exceed MAX_MATCHES_INPUT_LENGTH guard --- .../frontend-engine/yup/custom-conditions.spec.ts | 2 +- src/__tests__/utils/regex-helper.spec.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts b/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts index 67195cba1..ec755c8ce 100644 --- a/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts +++ b/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts @@ -32,7 +32,7 @@ describe("notMatches", () => { it("should reject an overly long value instead of testing it against the pattern", () => { const schema = buildSchema("/^(a+)+$/"); - const maliciousValue = `${"a".repeat(600)}!`; + const maliciousValue = `${"a".repeat(1000)}!`; const start = Date.now(); expect(TestHelper.getError(() => schema.validateSync(maliciousValue)).message).toBe(ERROR_MESSAGE); diff --git a/src/__tests__/utils/regex-helper.spec.ts b/src/__tests__/utils/regex-helper.spec.ts index d7eeafb63..6cb72bea1 100644 --- a/src/__tests__/utils/regex-helper.spec.ts +++ b/src/__tests__/utils/regex-helper.spec.ts @@ -32,7 +32,7 @@ describe("regex-helper", () => { expect(RegexHelper.safeTestRegex(/^hello/, "goodbye world")).toBe(false); }); - it("should not hang and should return false when value exceeds the safe length bound", () => { + it("should return false when value exceeds the safe length bound", () => { const maliciousValue = `${"a".repeat(RegexHelper.MAX_MATCHES_INPUT_LENGTH + 1)}!`; const start = Date.now(); @@ -41,9 +41,7 @@ describe("regex-helper", () => { }); it("should return false when a short value does not match the pattern", () => { - const nearMatch = `${"a".repeat(25)}!`; - - expect(RegexHelper.safeTestRegex(/^(a+)+$/, nearMatch)).toBe(false); + expect(RegexHelper.safeTestRegex(/^[0-9]+$/, "abc")).toBe(false); }); }); }); From 98d339779162182a5b09a45861e09350c6109fa2 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 18:20:14 +0800 Subject: [PATCH 20/22] [MOL-22453][SX] Tests: fix masked-field repeat(600) to exceed MAX_MATCHES_INPUT_LENGTH guard --- .../fields/masked-field/masked-field.spec.tsx | 146 ++++-------------- 1 file changed, 34 insertions(+), 112 deletions(-) diff --git a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx index 6a764c903..f487fa01d 100644 --- a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx +++ b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx @@ -1,61 +1,33 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import cloneDeep from "lodash/cloneDeep"; -import merge from "lodash/merge"; import { FrontendEngine } from "../../../../components"; import { IMaskedFieldSchema } from "../../../../components/fields"; import { ERROR_MESSAGES } from "../../../../components/shared"; -import { IFrontendEngineData, IFrontendEngineRef } from "../../../../components/types"; +import { IFrontendEngineData } from "../../../../components/types"; import { RegexHelper } from "../../../../utils"; import { ERROR_MESSAGE, - FRONTEND_ENGINE_ID, - FrontendEngineWithCustomButton, - TOverrideSchema, + createRenderComponent, getErrorMessage, getField, getResetButton, - getResetButtonProps, getSubmitButton, - getSubmitButtonProps, } from "../../../common"; -import { labelTestSuite } from "../../../common/tests"; -import { warningTestSuite } from "../../../common/tests/warnings"; +import { dirtyStateTestSuite, labelTestSuite, warningTestSuite } from "../../../common/tests"; const SUBMIT_FN = jest.fn(); const COMPONENT_ID = "field"; const COMPONENT_LABEL = "Masked field"; const UI_TYPE = "masked-field"; -const JSON_SCHEMA: IFrontendEngineData = { - id: FRONTEND_ENGINE_ID, - sections: { - section: { - uiType: "section", - children: { - [COMPONENT_ID]: { - label: COMPONENT_LABEL, - uiType: UI_TYPE, - maskRange: [0, 100], - }, - ...getSubmitButtonProps(), - ...getResetButtonProps(), - }, - }, - }, -}; -const renderComponent = (overrideField?: Partial | undefined, overrideSchema?: TOverrideSchema) => { - const json: IFrontendEngineData = merge(cloneDeep(JSON_SCHEMA), overrideSchema); - merge(json, { - sections: { - section: { - children: { - [COMPONENT_ID]: overrideField, - }, - }, - }, - }); - return render(); -}; +const { renderComponent, schema } = createRenderComponent({ + componentId: COMPONENT_ID, + baseSchema: { + label: COMPONENT_LABEL, + uiType: UI_TYPE, + maskRange: [0, 100], + }, + submitFn: SUBMIT_FN, +}); const getMaskedField = (): HTMLElement => { return getField("textbox", COMPONENT_LABEL); @@ -105,7 +77,7 @@ describe(UI_TYPE, () => { }); it("should not hang when a long value arrives via defaultValues", () => { - const maliciousValue = `${"a".repeat(600)}!`; + const maliciousValue = `${"a".repeat(1000)}!`; const start = Date.now(); renderComponent( @@ -119,16 +91,16 @@ describe(UI_TYPE, () => { ); }); - it("should clamp an already-loaded long value at render time when maskRegex changes at runtime, not only when the value itself changes", () => { - const maliciousValue = `${"a".repeat(600)}!`; - const withoutMaskRegex: IFrontendEngineData = merge(cloneDeep(JSON_SCHEMA), { - defaultValues: { [COMPONENT_ID]: maliciousValue }, - }); + it("should clamp an already-loaded long value at render time when maskRegex changes at runtime", () => { + const maliciousValue = `${"a".repeat(1000)}!`; + const withoutMaskRegex: IFrontendEngineData = JSON.parse(JSON.stringify(schema)); + Object.assign(withoutMaskRegex, { defaultValues: { [COMPONENT_ID]: maliciousValue } }); const { rerender } = render(); - const withMaskRegex: IFrontendEngineData = cloneDeep(withoutMaskRegex); - merge(withMaskRegex, { - sections: { section: { children: { [COMPONENT_ID]: { maskRange: null, maskRegex: "/^(a+)+$/" } } } }, + const withMaskRegex: IFrontendEngineData = JSON.parse(JSON.stringify(withoutMaskRegex)); + Object.assign(withMaskRegex.sections.section.children[COMPONENT_ID] as object, { + maskRange: null, + maskRegex: "/^(a+)+$/", }); const start = Date.now(); @@ -180,16 +152,20 @@ describe(UI_TYPE, () => { expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: defaultValue })); }); - it("should pass other props into the field", () => { + it("should pass disabled and placeholder props into the field", () => { renderComponent({ placeholder: "placeholder", - readOnly: true, disabled: true, }); expect(getMaskedField()).toHaveAttribute("placeholder", "placeholder"); - expect(getMaskedField()).toHaveAttribute("readonly"); - expect(getMaskedField()).toBeDisabled(); + expect(getMaskedField()).toHaveAttribute("aria-disabled", "true"); + }); + + it("should render masked readonly state when readOnly is true", () => { + renderComponent({ readOnly: true }); + + expect(screen.getByTestId("masked-input-readonly-button")).toBeInTheDocument(); }); it("should mask based on regex", async () => { @@ -250,65 +226,11 @@ describe(UI_TYPE, () => { }); }); - describe("dirty state", () => { - let formIsDirty: boolean; - const handleClick = (ref: React.MutableRefObject) => { - formIsDirty = ref.current.isDirty; - }; - - beforeEach(() => { - formIsDirty = undefined; - }); - - it("should mount without setting field state as dirty", () => { - render(); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); - - expect(formIsDirty).toBe(false); - }); - - it("should set form state as dirty if user modifies the field", () => { - render(); - fireEvent.change(getMaskedField(), { target: { value: "world" } }); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); - - expect(formIsDirty).toBe(true); - }); - - it("should support default value without setting form state as dirty", () => { - render( - - ); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); - - expect(formIsDirty).toBe(false); - }); - - it("should reset and revert form dirty state to false", () => { - render(); - fireEvent.change(getMaskedField(), { target: { value: "world" } }); - fireEvent.click(getResetButton()); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); - - expect(formIsDirty).toBe(false); - }); - - it("should reset to default value without setting form state as dirty", () => { - render( - - ); - fireEvent.change(getMaskedField(), { target: { value: "world" } }); - fireEvent.click(getResetButton()); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); - - expect(formIsDirty).toBe(false); - }); + dirtyStateTestSuite({ + schema, + componentId: COMPONENT_ID, + defaultValue: "hello", + modifyField: () => fireEvent.change(getMaskedField(), { target: { value: "world" } }), }); labelTestSuite(renderComponent); From c8005368b894fded86d31096442cb29b5d2644fd Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 18:40:49 +0800 Subject: [PATCH 21/22] [MOL-22453][SX] Tests: fix masked-field hang tests and max-validation values --- .../components/fields/masked-field/masked-field.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx index f487fa01d..ceb621483 100644 --- a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx +++ b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx @@ -131,9 +131,9 @@ describe(UI_TYPE, () => { }); it("should not reject an oversized value when an explicit max validation rule already permits that length", async () => { - const value = "a".repeat(RegexHelper.MAX_MATCHES_INPUT_LENGTH + 1); + const value = "a".repeat(1001); // above MAX_MATCHES_INPUT_LENGTH (1000) but within explicit max: 1100 renderComponent( - { maskRange: null, maskRegex: "/^(hello)/g", validation: [{ max: 1000 }] }, + { maskRange: null, maskRegex: "/^(hello)/g", validation: [{ max: 1100 }] }, { defaultValues: { [COMPONENT_ID]: value } } ); From 2a481ad5bb370aadc05c86dd573ed5be2fb32658 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Tue, 22 Sep 2026 11:55:31 +0800 Subject: [PATCH 22/22] [MOL-22453][SX] Tests: fix v1-incompatible test patterns in masked-field and image-upload specs --- .../fields/image-upload/image-upload.spec.tsx | 157 +++++++++++++++--- .../fields/masked-field/masked-field.spec.tsx | 130 ++++++++++++--- 2 files changed, 234 insertions(+), 53 deletions(-) diff --git a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx index b1dab377b..34ecbdd6a 100644 --- a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx +++ b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx @@ -5,7 +5,7 @@ import { FrontendEngine } from "../../../../components"; import { EImageStatus, IImageUploadSchema } from "../../../../components/fields"; import { ERROR_MESSAGES } from "../../../../components/shared"; import { IFrontendEngineData, IFrontendEngineProps, IFrontendEngineRef } from "../../../../components/types"; -import { AxiosApiClient, FileHelper, ImageHelper } from "../../../../utils"; +import { AxiosApiClient, FileHelper, ImageHelper, WindowHelper } from "../../../../utils"; import * as IdHelper from "../../../../utils/id-helper"; import { ERROR_MESSAGE, @@ -20,8 +20,6 @@ import { getSubmitButton, getSubmitButtonProps, } from "../../../common"; -import * as WindowHelper from "../../../../utils/hooks/use-window-helper"; -import { dirtyStateTestSuite } from "../../../common/tests"; const METADATA = { dateTimeOriginal: "2009:10:10 04:09:20", lat: 22.316033333333333, lng: 114.17031666666666 }; @@ -740,7 +738,7 @@ describe("image-upload", () => { describe("mobile", () => { beforeEach(async () => { - jest.spyOn(WindowHelper, "useWindowHelper").mockReturnValue(() => true); + jest.spyOn(WindowHelper, "isMobileView").mockReturnValue(true); await renderComponent({ files: [FILE_1], @@ -756,7 +754,7 @@ describe("image-upload", () => { await waitFor(() => { expect(screen.getByText(REVIEW_MODAL_TEXT)).toBeVisible(); }); - expect(screen.queryByText(REVIEW_PROMPT_TEXT)).not.toBeInTheDocument(); + expect(screen.queryByText(REVIEW_PROMPT_TEXT)).not.toBeVisible(); }); }); }); @@ -1374,8 +1372,12 @@ describe("image-upload", () => { }); }); - dirtyStateTestSuite({ - schema: { + describe("dirty state", () => { + let formIsDirty: boolean; + const handleClick = (ref: React.MutableRefObject) => { + formIsDirty = ref.current.isDirty; + }; + const json: IFrontendEngineData = { id: FRONTEND_ENGINE_ID, sections: { section: { @@ -1391,15 +1393,25 @@ describe("image-upload", () => { }, }, }, - }, - componentId: COMPONENT_ID, - defaultValue: [ - { - fileName: FILE_1.name, - dataURL: JPG_BASE64, - }, - ], - modifyField: async () => { + }; + + beforeEach(() => { + formIsDirty = undefined; + jest.spyOn(ImageHelper, "convertBlob").mockResolvedValue(JPG_BASE64); + jest.spyOn(ImageHelper, "getMetadata").mockResolvedValue(METADATA); + jest.spyOn(FileHelper, "dataUrlToBlob").mockResolvedValue(FILE_1); + jest.spyOn(FileHelper, "getType").mockResolvedValue({ ext: "jpg", mime: "image/jpeg" }); + }); + + it("should mount without setting field state as dirty", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + + expect(formIsDirty).toBe(false); + }); + + it("should set form state as dirty if user adds an image", async () => { + render(); await act(async () => { fireEvent.change(getDragInputUploadField(), { target: { @@ -1408,17 +1420,108 @@ describe("image-upload", () => { }); await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload }); - }, - modifyAndRemoveField: async () => { + fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + + expect(formIsDirty).toBe(true); + }); + + it("should support default value without setting form state as dirty", async () => { + render( + + ); + await act(async () => { + await flushPromise(); + }); + fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + + expect(formIsDirty).toBe(false); + }); + + it("should set form state as dirty if user removes an image", async () => { + render( + + ); await waitFor(() => fireEvent.click(screen.getByTestId(`${COMPONENT_ID}-file-item-1__btn-delete`))); await flushPromise(); - }, - beforeEach: () => { - jest.spyOn(ImageHelper, "convertBlob").mockResolvedValue(JPG_BASE64); - jest.spyOn(ImageHelper, "getMetadata").mockResolvedValue(METADATA); - jest.spyOn(FileHelper, "dataUrlToBlob").mockResolvedValue(FILE_1); - jest.spyOn(FileHelper, "getType").mockResolvedValue({ ext: "jpg", mime: "image/jpeg" }); - }, + fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + + expect(formIsDirty).toBe(true); + }); + + it("should reset and revert form dirty state to false", async () => { + render(); + await act(async () => { + fireEvent.change(getDragInputUploadField(), { + target: { + files: [FILE_1], + }, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload + await flushPromise(100); + await waitFor(() => fireEvent.click(getResetButton())); + }); + fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + + expect(formIsDirty).toBe(false); + }); + + it("should reset to default value without setting form state as dirty", async () => { + render( + + ); + await act(async () => { + fireEvent.change(getDragInputUploadField(), { + target: { + files: [FILE_2], + }, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload + await flushPromise(100); + await waitFor(() => fireEvent.click(getResetButton())); + }); + fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + + expect(formIsDirty).toBe(false); + }); }); describe("when capture value is specified", () => { @@ -1478,9 +1581,9 @@ describe("image-upload", () => { ); await waitFor(() => { expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); + expect(getField("button", `thumbnail of ${FILE_2.name}`)).toBeInTheDocument(); + expect(getField("button", `thumbnail of test (1).jpg`)).toBeInTheDocument(); }); - expect(getField("button", `thumbnail of ${FILE_2.name}`)).toBeInTheDocument(); - expect(getField("button", `thumbnail of test (1).jpg`)).toBeInTheDocument(); }); it("should show exceed error when add over the max number", async () => { diff --git a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx index ceb621483..94d9b5c09 100644 --- a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx +++ b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx @@ -1,33 +1,61 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import cloneDeep from "lodash/cloneDeep"; +import merge from "lodash/merge"; import { FrontendEngine } from "../../../../components"; import { IMaskedFieldSchema } from "../../../../components/fields"; import { ERROR_MESSAGES } from "../../../../components/shared"; -import { IFrontendEngineData } from "../../../../components/types"; +import { IFrontendEngineData, IFrontendEngineRef } from "../../../../components/types"; import { RegexHelper } from "../../../../utils"; import { ERROR_MESSAGE, - createRenderComponent, + FRONTEND_ENGINE_ID, + FrontendEngineWithCustomButton, + TOverrideSchema, getErrorMessage, getField, getResetButton, + getResetButtonProps, getSubmitButton, + getSubmitButtonProps, } from "../../../common"; -import { dirtyStateTestSuite, labelTestSuite, warningTestSuite } from "../../../common/tests"; +import { labelTestSuite } from "../../../common/tests"; +import { warningTestSuite } from "../../../common/tests/warnings"; const SUBMIT_FN = jest.fn(); const COMPONENT_ID = "field"; const COMPONENT_LABEL = "Masked field"; const UI_TYPE = "masked-field"; - -const { renderComponent, schema } = createRenderComponent({ - componentId: COMPONENT_ID, - baseSchema: { - label: COMPONENT_LABEL, - uiType: UI_TYPE, - maskRange: [0, 100], +const JSON_SCHEMA: IFrontendEngineData = { + id: FRONTEND_ENGINE_ID, + sections: { + section: { + uiType: "section", + children: { + [COMPONENT_ID]: { + label: COMPONENT_LABEL, + uiType: UI_TYPE, + maskRange: [0, 100], + }, + ...getSubmitButtonProps(), + ...getResetButtonProps(), + }, + }, }, - submitFn: SUBMIT_FN, -}); +}; + +const renderComponent = (overrideField?: Partial | undefined, overrideSchema?: TOverrideSchema) => { + const json: IFrontendEngineData = merge(cloneDeep(JSON_SCHEMA), overrideSchema); + merge(json, { + sections: { + section: { + children: { + [COMPONENT_ID]: overrideField, + }, + }, + }, + }); + return render(); +}; const getMaskedField = (): HTMLElement => { return getField("textbox", COMPONENT_LABEL); @@ -93,7 +121,7 @@ describe(UI_TYPE, () => { it("should clamp an already-loaded long value at render time when maskRegex changes at runtime", () => { const maliciousValue = `${"a".repeat(1000)}!`; - const withoutMaskRegex: IFrontendEngineData = JSON.parse(JSON.stringify(schema)); + const withoutMaskRegex: IFrontendEngineData = JSON.parse(JSON.stringify(JSON_SCHEMA)); Object.assign(withoutMaskRegex, { defaultValues: { [COMPONENT_ID]: maliciousValue } }); const { rerender } = render(); @@ -152,20 +180,16 @@ describe(UI_TYPE, () => { expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: defaultValue })); }); - it("should pass disabled and placeholder props into the field", () => { + it("should pass other props into the field", () => { renderComponent({ placeholder: "placeholder", + readOnly: true, disabled: true, }); expect(getMaskedField()).toHaveAttribute("placeholder", "placeholder"); - expect(getMaskedField()).toHaveAttribute("aria-disabled", "true"); - }); - - it("should render masked readonly state when readOnly is true", () => { - renderComponent({ readOnly: true }); - - expect(screen.getByTestId("masked-input-readonly-button")).toBeInTheDocument(); + expect(getMaskedField()).toHaveAttribute("readonly"); + expect(getMaskedField()).toBeDisabled(); }); it("should mask based on regex", async () => { @@ -226,11 +250,65 @@ describe(UI_TYPE, () => { }); }); - dirtyStateTestSuite({ - schema, - componentId: COMPONENT_ID, - defaultValue: "hello", - modifyField: () => fireEvent.change(getMaskedField(), { target: { value: "world" } }), + describe("dirty state", () => { + let formIsDirty: boolean; + const handleClick = (ref: React.MutableRefObject) => { + formIsDirty = ref.current.isDirty; + }; + + beforeEach(() => { + formIsDirty = undefined; + }); + + it("should mount without setting field state as dirty", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + + expect(formIsDirty).toBe(false); + }); + + it("should set form state as dirty if user modifies the field", () => { + render(); + fireEvent.change(getMaskedField(), { target: { value: "world" } }); + fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + + expect(formIsDirty).toBe(true); + }); + + it("should support default value without setting form state as dirty", () => { + render( + + ); + fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + + expect(formIsDirty).toBe(false); + }); + + it("should reset and revert form dirty state to false", () => { + render(); + fireEvent.change(getMaskedField(), { target: { value: "world" } }); + fireEvent.click(getResetButton()); + fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + + expect(formIsDirty).toBe(false); + }); + + it("should reset to default value without setting form state as dirty", () => { + render( + + ); + fireEvent.change(getMaskedField(), { target: { value: "world" } }); + fireEvent.click(getResetButton()); + fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + + expect(formIsDirty).toBe(false); + }); }); labelTestSuite(renderComponent);