diff --git a/.env.sample b/.env.sample index 91c3f13..06d706e 100644 --- a/.env.sample +++ b/.env.sample @@ -14,3 +14,6 @@ COMPOSE_EXTERNAL_NETWORK=false # Set this to traefik-net in prod COMPOSE_NETWORK_NAME= +# Not required for local dev, unless the backend is configured to require captchas +RECAPTCHA_V2_KEY= +RECAPTCHA_V3_KEY= \ No newline at end of file diff --git a/.github/workflows/deploy-to-k8s.yaml b/.github/workflows/deploy-to-k8s.yaml index b3307c6..fce83e2 100644 --- a/.github/workflows/deploy-to-k8s.yaml +++ b/.github/workflows/deploy-to-k8s.yaml @@ -49,7 +49,9 @@ jobs: --set meshforms.s3_bucket_name="${{ secrets.S3_BUCKET_NAME }}" \ --set meshforms.s3_base_name="${{ secrets.S3_BASE_NAME }}" \ --set image.tag="${{ inputs.environment }}" \ - --set ingress.hosts[0].host="${{ vars.INGRESS_HOST }}",ingress.hosts[0].paths[0].path=/,ingress.hosts[0].paths[0].pathType=Prefix + --set ingress.hosts[0].host="${{ vars.INGRESS_HOST }}",ingress.hosts[0].paths[0].path=/,ingress.hosts[0].paths[0].pathType=Prefix \ + --set meshforms.recaptcha_v2_key="${{ secrets.RECAPTCHA_V2_KEY }}" \ + --set meshforms.recaptcha_v3_key="${{ secrets.RECAPTCHA_V3_KEY }}" # Rolling restart kubectl --kubeconfig ./config --server https://${{ secrets.SSH_TARGET_IP }}:6443 -n ${{ vars.APP_NAMESPACE }} rollout restart deploy diff --git a/.github/workflows/playwright-captcha.yml b/.github/workflows/playwright-captcha.yml new file mode 100644 index 0000000..44e6f25 --- /dev/null +++ b/.github/workflows/playwright-captcha.yml @@ -0,0 +1,57 @@ +name: Playwright reCAPTCHA Tests +permissions: read-all +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] +jobs: + test: + name: Run Playwright reCAPTCHA Tests + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 + with: + node-version: lts/* + - name: Setup Minio + run: | + docker run -d -p 9000:9000 --name minio \ + -e "MINIO_ACCESS_KEY=sampleaccesskey" \ + -e "MINIO_SECRET_KEY=samplesecretkey" \ + -e "MINIO_DEFAULT_BUCKETS=meshdb-join-form-log" \ + -v /tmp/data:/data \ + -v /tmp/config:/root/.minio \ + minio/minio server /data + + export AWS_ACCESS_KEY_ID=sampleaccesskey + export AWS_SECRET_ACCESS_KEY=samplesecretkey + export AWS_EC2_METADATA_DISABLED=true + aws --endpoint-url http://127.0.0.1:9000/ s3 mb s3://meshdb-join-form-log + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: RUN_CAPTCHA=true npx playwright test -g 'user triggered captchaV2' + env: + NEXT_PUBLIC_MESHDB_URL: https://127.0.0.1:8000 # Throwaway to make the mock work + MESHDB_URL: https://127.0.0.1:8000 # Throwaway to make the mock work + # We now check the JoinRecord stuff, so submit that too. + JOIN_RECORD_BUCKET_NAME: meshdb-join-form-log + JOIN_RECORD_PREFIX: dev-join-form-submissions + S3_ENDPOINT: http://127.0.0.1:9000 + AWS_ACCESS_KEY_ID: sampleaccesskey + AWS_SECRET_ACCESS_KEY: samplesecretkey + AWS_REGION: us-east-1 + # This is an invisible key that says the user is a robot + RECAPTCHA_V3_KEY: ${{ secrets.TESTING_RECAPTCHA_V3_KEY }} + RECAPTCHA_V2_KEY: ${{ secrets.TESTING_RECAPTCHA_V2_KEY }} + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 5f2682a..3c8efd4 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -34,7 +34,7 @@ jobs: - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Run Playwright tests - run: npx playwright test --grep-invert 'fail meshdb hard down and s3 hard down' + run: npx playwright test env: MESHDB_URL: https://127.0.0.1:8000 # Throwaway to make the mock work # We now check the JoinRecord stuff, so submit that too. diff --git a/infra/helm/meshforms/templates/secrets.yaml b/infra/helm/meshforms/templates/secrets.yaml index 94bdb3e..b423ac6 100644 --- a/infra/helm/meshforms/templates/secrets.yaml +++ b/infra/helm/meshforms/templates/secrets.yaml @@ -9,3 +9,5 @@ data: S3_BUCKET_NAME: {{ .Values.meshforms.s3_bucket_name | b64enc | quote }} S3_BASE_NAME: {{ .Values.meshforms.s3_base_name | b64enc | quote }} MESHDB_URL: {{ .Values.meshforms.meshdb_url | b64enc | quote }} + RECAPTCHA_V2_KEY: {{ .Values.meshforms.recaptcha_v2_key | default "" | b64enc | quote }} + RECAPTCHA_V3_KEY: {{ .Values.meshforms.recaptcha_v3_key | default "" | b64enc | quote }} diff --git a/messages/en.json b/messages/en.json index 99ec441..78406e3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,4 +1,7 @@ { + "Manifest": { + "name": "NYC Mesh MeshForms" + }, "LocaleSwitcher": { "label": "Language", "locale": "{locale, select, en {🇺🇸 English} es {🇪🇸 Español} fr {🇫🇷 Français} ht {🇭🇹 Haitian Creole} zh {🇨🇳 中文} other {Unknown}}" @@ -31,7 +34,8 @@ "submit": { "submit": "Submit", "loading": "Loading...", - "thanks": "Thanks!" + "thanks": "Thanks!", + "goHome": "Go Home" } }, "states": { @@ -44,6 +48,12 @@ "minutes": "5-10 minutes", "days": "2-3 days", "support": "If you do not see the email, please check your \"Spam\" folder, or email support@nycmesh.net for help." + }, + "errors": { + "error": "Could not submit Join Form:", + "errorTryAgain": "Could not submit Join Form. Please try again later, or contact support@nycmesh.net for assistance.", + "confirm": "Please confirm some information", + "captchaFail": "Please complete an additional verification step to confirm your submission" } } } diff --git a/package-lock.json b/package-lock.json index c9cb5db..0947d11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "ag-grid-react": "^32.1.0", "aws-sdk": "^2.1659.0", "bootstrap": "^5.3.2", + "deepmerge": "^4.3.1", "libphonenumber-js": "^1.11.11", "msw": "^2.3.5", "next": "latest", @@ -27,6 +28,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-error-boundary": "^4.0.12", + "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.53.0", "react-phone-number-input": "^3.3.9", "react-select": "^5.8.0", @@ -38,6 +40,7 @@ "devDependencies": { "@playwright/test": "^1.45.3", "@types/react": "^18.2.48", + "@types/react-google-recaptcha": "^2.1.9", "dotenv": "^16.4.5" } }, @@ -2485,6 +2488,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-google-recaptcha": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz", + "integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -2987,6 +3000,14 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -3997,6 +4018,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-async-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", + "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -4020,6 +4054,19 @@ "react": ">=16.13.1" } }, + "node_modules/react-google-recaptcha": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", + "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.0", + "react-async-script": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-hook-form": { "version": "7.53.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.0.tgz", diff --git a/package.json b/package.json index 7faf11b..ae3f9d8 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "ag-grid-react": "^32.1.0", "aws-sdk": "^2.1659.0", "bootstrap": "^5.3.2", + "deepmerge": "^4.3.1", "libphonenumber-js": "^1.11.11", "msw": "^2.3.5", "next": "latest", @@ -28,6 +29,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-error-boundary": "^4.0.12", + "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.53.0", "react-phone-number-input": "^3.3.9", "react-select": "^5.8.0", @@ -39,6 +41,7 @@ "devDependencies": { "@playwright/test": "^1.45.3", "@types/react": "^18.2.48", + "@types/react-google-recaptcha": "^2.1.9", "dotenv": "^16.4.5" } } diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 471ece6..64124f5 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import { routing } from "@/i18n/routing"; import { getMessages } from "next-intl/server"; import { NextIntlClientProvider } from "next-intl"; +import deepmerge from "deepmerge"; export default async function RootLayout({ children, @@ -12,12 +13,14 @@ export default async function RootLayout({ params: { locale: string }; }) { // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!locale || !routing.locales.includes(locale as any)) { notFound(); } // Provide all messages to the client - const messages = await getMessages(); + const localeMessages = await getMessages({ locale }); + const defaultMessages = await getMessages({ locale: "en" }); + const messages = deepmerge(defaultMessages, localeMessages); return ( diff --git a/src/components/JoinForm/JoinForm.module.scss b/src/components/JoinForm/JoinForm.module.scss index 03b0fca..1c4e039 100644 --- a/src/components/JoinForm/JoinForm.module.scss +++ b/src/components/JoinForm/JoinForm.module.scss @@ -105,3 +105,15 @@ .hidden { display: none; } + +.captchaDisclaimer { + font-size: 80%; + color: #9a9a9a; + margin-top: 30px; + margin-bottom: 30px; +} + +.captchaDisclaimer > a { + color: #000; + margin-left: 3px; +} diff --git a/src/components/JoinForm/JoinForm.tsx b/src/components/JoinForm/JoinForm.tsx index e4cab7f..c551c16 100644 --- a/src/components/JoinForm/JoinForm.tsx +++ b/src/components/JoinForm/JoinForm.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import styles from "./JoinForm.module.scss"; +import "./hide_recaptcha_badge.css"; import { parsePhoneNumberFromString } from "libphonenumber-js"; import { CircularProgress, @@ -14,11 +15,12 @@ import { import { ToastContainer, toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import { saveJoinRecordToS3 } from "@/lib/join_record"; -import { getMeshDBAPIEndpoint } from "@/lib/endpoint"; +import { getMeshDBAPIEndpoint, getRecaptchaKeys } from "@/lib/endpoint"; import InfoConfirmationDialog from "../InfoConfirmation/InfoConfirmation"; import { JoinRecord } from "@/lib/types"; -import { useTranslations } from "next-intl"; +import { useTranslations, useLocale } from "next-intl"; import LocaleSwitcher from "../LocaleSwitcher"; +import ReCAPTCHA from "react-google-recaptcha"; export class JoinFormValues { constructor( @@ -77,7 +79,14 @@ export default function JoinForm() { defaultValues: defaultFormValues, }); + const recaptchaV3Ref = React.useRef(null); + const recaptchaV2Ref = React.useRef(null); + + const locale = useLocale(); + const [isLoading, setIsLoading] = useState(false); + const [isProbablyABot, setIsProbablyABot] = useState(false); + const [checkBoxCaptchaToken, setCheckBoxCaptchaToken] = useState(""); const [isInfoConfirmationDialogueOpen, setIsInfoConfirmationDialogueOpen] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false); @@ -85,8 +94,24 @@ export default function JoinForm() { const [isBadPhoneNumber, setIsBadPhoneNumber] = useState(false); const [joinRecordKey, setJoinRecordKey] = useState(""); + const [recaptchaV2Key, setRecaptchaV2Key] = useState( + undefined, + ); + const [recaptchaV3Key, setRecaptchaV3Key] = useState( + undefined, + ); + const [reCaptchaError, setReCaptchaError] = useState(false); + const isBeta = true; + useEffect(() => { + (async () => { + const [v2_key, v3_key] = await getRecaptchaKeys(); + setRecaptchaV2Key(v2_key); + setRecaptchaV3Key(v3_key); + })(); + }, [setRecaptchaV2Key, setRecaptchaV3Key]); + // Store the values submitted by the user or returned by the server const [infoToConfirm, setInfoToConfirm] = useState>([ { key: "" as keyof JoinFormValues, original: "", new: "" }, @@ -177,11 +202,32 @@ export default function JoinForm() { // Attempt to submit the Join Form try { + // Get the v3 captcha token. Per the google docs, the implicit token must be retrieved on form submission, + // so that the token doesn't expire before server side validation + let recaptchaInvisibleToken = ""; + if (recaptchaV3Ref?.current && !reCaptchaError) { + recaptchaInvisibleToken = + (await recaptchaV3Ref.current.executeAsync()) ?? ""; + recaptchaV3Ref.current.reset(); + } else { + console.warn( + "No ref found for the recaptchaV3 component, or component is in error state, not including captcha token in HTTP request", + ); + } + const response = await fetch( `${await getMeshDBAPIEndpoint()}/api/v1/join/`, { method: "POST", body: JSON.stringify(joinFormSubmission), + headers: { + "X-Recaptcha-V2-Token": checkBoxCaptchaToken + ? checkBoxCaptchaToken + : "", + "X-Recaptcha-V3-Token": recaptchaInvisibleToken + ? recaptchaInvisibleToken + : "", + }, }, ); const j = await response.json(); @@ -215,6 +261,7 @@ export default function JoinForm() { console.debug("Join Form submitted successfully"); setIsLoading(false); setIsSubmitted(true); + setIsProbablyABot(false); return; } @@ -246,7 +293,22 @@ export default function JoinForm() { setInfoToConfirm(needsConfirmation); setIsInfoConfirmationDialogueOpen(true); - toast.warning("Please confirm some information"); + toast.warning(t("errors.confirm")); + return; + } + + // If the server said the recaptcha token indicates this was a bot (HTTP 401), prompt the user with the + // interactive "checkbox" V2 captcha. However, if they have already submitted a checkbox captcha + // and are still seeing a 401, something has gone wrong - fall back to the generic 4xx error handling logic below + if (record.code == 401 && !checkBoxCaptchaToken) { + toast.warning(t("errors.captchaFail")); + setIsProbablyABot(true); + setIsSubmitted(false); + setIsLoading(false); + + console.error( + "Request failed invisible captcha verification, user can try again with checkbox validation", + ); return; } @@ -266,9 +328,16 @@ export default function JoinForm() { const detail = error.detail; // This looks disgusting when Debug is on in MeshDB because it replies with HTML. // There's probably a way to coax the exception out of the response somewhere - toast.error(`Could not submit Join Form: ${detail}`); + toast.error(t("errors.error") + " " + detail.toString()); console.error(`An error occurred: ${detail}`); setIsLoading(false); + + // Clear the checkbox captcha if it exists, to allow the user to retry if needed + if (recaptchaV2Ref.current) { + recaptchaV2Ref.current.reset(); + setCheckBoxCaptchaToken(""); + } + return; } @@ -284,9 +353,7 @@ export default function JoinForm() { } else { // If MeshDB is down AND we failed to save the Join Record, then we should // probably let the member know to try again later. - toast.error( - `Could not submit Join Form. Please try again later, or contact support@nycmesh.net for assistance.`, - ); + toast.error(t("errors.errorTryAgain")); setIsLoading(false); } @@ -434,6 +501,47 @@ export default function JoinForm() { ), })} + {/* This first captcha isn't actually displayed, it just silently collects user metrics and generates a token */} + {recaptchaV3Key ? ( + { + console.error( + "Encountered an error while initializing or querying captcha. " + + "Disabling some frontend captcha features to avoid hangs. " + + "Are the recaptcha keys set correctly in the env variables?", + ); + setReCaptchaError(true); + }} + /> + ) : ( + <> + )} + {/* This second captcha is the traditional "I'm not a robot" checkbox, + only shown if the user gets 401'ed due to a low score on the above captcha */} + {isProbablyABot && recaptchaV2Key ? ( + setCheckBoxCaptchaToken(newToken ?? "")} + onErrored={() => { + console.error( + "Encountered an error while initializing or querying captcha. " + + "Disabling all frontend captcha features to avoid hangs. " + + "Are the recaptcha keys set correctly in the env variables?", + ); + setReCaptchaError(true); + }} + /> + ) : ( + <> + )} {/*

State Debugger

@@ -447,7 +555,11 @@ export default function JoinForm() {
+
+ This site is protected by reCAPTCHA and the Google + Privacy Policy and + + Terms of Service + {" "} + apply. +
@@ -497,7 +617,7 @@ export default function JoinForm() { size="large" href="https://nycmesh.net/" > - Go Home + {t("fields.submit.goHome")}
diff --git a/src/components/JoinForm/hide_recaptcha_badge.css b/src/components/JoinForm/hide_recaptcha_badge.css new file mode 100644 index 0000000..545f6b0 --- /dev/null +++ b/src/components/JoinForm/hide_recaptcha_badge.css @@ -0,0 +1,3 @@ +.grecaptcha-badge { + visibility: hidden; +} diff --git a/src/lib/endpoint.ts b/src/lib/endpoint.ts index 721e220..0a4ffcd 100644 --- a/src/lib/endpoint.ts +++ b/src/lib/endpoint.ts @@ -7,3 +7,16 @@ export async function getMeshDBAPIEndpoint() { } return process.env.MESHDB_URL; } + +// Literally just ask the server what captcha keys to use +export async function getRecaptchaKeys() { + if (!process.env.RECAPTCHA_V2_KEY) { + console.warn("RECAPTCHA_V2_KEY not set"); + } + + if (!process.env.RECAPTCHA_V3_KEY) { + console.warn("RECAPTCHA_V3_KEY not set"); + } + + return [process.env.RECAPTCHA_V2_KEY, process.env.RECAPTCHA_V3_KEY]; +} diff --git a/tests/02_join_form.spec.ts b/tests/02_join_form.spec.ts index 8a49b33..eaee3f0 100644 --- a/tests/02_join_form.spec.ts +++ b/tests/02_join_form.spec.ts @@ -415,3 +415,36 @@ test("fail nj", async ({ page }) => { ); } }); + +test.describe("user triggered captchaV2", () => { + test.skip(process.env.RUN_CAPTCHA !== "true"); + test("user triggered captchaV2", async ({ page }) => { + test.setTimeout(joinFormTimeout); + await page.goto("/join"); + + // Is the page title correct? + await expect(page).toHaveTitle(/Join Our Community Network!/); + + // Set up sample data. + let botTriggeringData: JoinFormValues = Object.assign({}, sampleData); + + await fillOutJoinForm(page, botTriggeringData); + + await submitAndCheckToast( + page, + "Please complete an additional verification step to confirm your submission", + ); + + await page.waitForTimeout(1000); + + // Make the robot check the "I'm not a robot" button (commit voter fraud) + await page + .locator("[title='reCAPTCHA']") + .nth(1) + .contentFrame() + .locator("[id='recaptcha-anchor']") + .click(); + + await submitSuccessExpected(page, unitTestTimeout); + }); +}); diff --git a/tests/mock/handlers.ts b/tests/mock/handlers.ts index d5e78a4..8d4cadf 100644 --- a/tests/mock/handlers.ts +++ b/tests/mock/handlers.ts @@ -73,6 +73,17 @@ export default [ return HttpResponse.json(r, { status: 409 }); } + // Mock response for if we want to trigger a captchaV2 response + if ( + request.headers.get("X-Recaptcha-V2-Token") === "" && + process.env.RECAPTCHA_V2_KEY + ) { + return HttpResponse.json( + { detail: "Captcha verification failed" }, + { status: 401 }, + ); + } + // If anything else is wrong with the form we got, then bail if (!isDeepStrictEqual(joinRequest, expectedAPIRequestData)) { console.error( diff --git a/tests/util.ts b/tests/util.ts index 1acdd62..a5674b2 100644 --- a/tests/util.ts +++ b/tests/util.ts @@ -14,7 +14,7 @@ export const sampleData: JoinFormValues = { state: "NY", zip_code: "11238", roof_access: true, - referral: "I googled it.", + referral: "Mock Sample Data", ncl: true, trust_me_bro: false, }; @@ -30,7 +30,7 @@ export const sampleJoinRecord: JoinRecord = { state: "NY", zip_code: "11238", roof_access: true, - referral: "I googled it.", + referral: "Mock Sample Data", ncl: true, trust_me_bro: false, submission_time: "2024-11-01T08:24:24", @@ -50,7 +50,7 @@ export const expectedTrustMeBroValues: JoinFormValues = { state: "NY", zip_code: "11238", roof_access: true, - referral: "I googled it.", + referral: "Mock Sample Data", ncl: true, trust_me_bro: false, }; @@ -66,7 +66,7 @@ export const sampleNJData: JoinFormValues = { zip_code: "07030", apartment: "1", roof_access: true, - referral: "I googled it.", + referral: "Mock Sample Data", ncl: true, trust_me_bro: false, };