From 532f3c5227542cacf57224ac975296d90063dc55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?txb=C3=AC?= <46839250+0xTxbi@users.noreply.github.com> Date: Mon, 30 Sep 2024 19:10:20 +0100 Subject: [PATCH] fix(unlock-app): Fix Checkout `paywallConfig` Extraction and Validation from URL Query Parameters (#14699) * convert checkout content to client component * improve paywallConfig utility * update tests --- .../utils/getConfigFromSearch.test.ts | 73 ++++++++---------- .../components/interface/checkout/index.tsx | 2 + unlock-app/src/utils/paywallConfig.ts | 74 +++++++++++++++---- 3 files changed, 92 insertions(+), 57 deletions(-) diff --git a/unlock-app/src/__tests__/utils/getConfigFromSearch.test.ts b/unlock-app/src/__tests__/utils/getConfigFromSearch.test.ts index b0a945ba2d6..97073828d31 100644 --- a/unlock-app/src/__tests__/utils/getConfigFromSearch.test.ts +++ b/unlock-app/src/__tests__/utils/getConfigFromSearch.test.ts @@ -1,76 +1,67 @@ -// TODO: remove, some part related to old checkout (callToAction) - import { getPaywallConfigFromQuery } from '../../utils/paywallConfig' -import { vi, beforeAll, afterAll, it, describe, expect } from 'vitest' - -let originalConsole: any -let error = vi.fn() +import { it, describe, expect } from 'vitest' const lock = '0x1234567890123456789012345678901234567890' const validConfig = { - callToAction: { - default: 'hi', - expired: 'there', - pending: 'pending', - confirmed: 'confirmed', - }, + title: 'Valid Title', + network: 1, + pessimistic: true, + skipRecipient: false, locks: { [lock]: { name: 'A Lock', }, }, icon: 'http://image.com/image.tiff', + minRecipients: 1, + maxRecipients: 5, } -describe('getConfigFromSearch', () => { - beforeAll(() => { - originalConsole = global.console - }) - beforeEach(() => { - error = vi.fn() - ;(global.console as any) = { error } - }) - afterAll(() => { - global.console = originalConsole - }) - +describe('getPaywallConfigFromQuery', () => { it('should be undefined if there is no paywall config', () => { - expect.assertions(2) - + expect.assertions(1) expect(getPaywallConfigFromQuery({})).toBeUndefined() - expect(error).not.toHaveBeenCalled() }) it('should be undefined if paywall config is malformed JSON', () => { - expect.assertions(2) - + expect.assertions(1) expect(getPaywallConfigFromQuery({ paywallConfig: '{' })).toBeUndefined() - expect(error).toHaveBeenCalledWith( - 'paywall config in URL not valid JSON, continuing with undefined' - ) }) it('should be undefined if paywall config does not pass validation', () => { - expect.assertions(2) - + expect.assertions(1) expect(getPaywallConfigFromQuery({ paywallConfig: '{}' })).toBeUndefined() - expect(error).toHaveBeenCalledWith( - 'paywall config in URL does not pass validation, continuing with undefined' - ) }) it('should return a paywall config otherwise', () => { - expect.assertions(2) - + expect.assertions(1) expect( getPaywallConfigFromQuery({ - paywallConfig: encodeURIComponent(JSON.stringify(validConfig)), + paywallConfig: JSON.stringify(validConfig), }) ).toEqual( expect.objectContaining({ icon: 'http://image.com/image.tiff', + title: 'Valid Title', + network: 1, + pessimistic: true, + skipRecipient: false, + minRecipients: 1, + maxRecipients: 5, }) ) - expect(error).not.toHaveBeenCalled() + }) + + it('should handle ReadonlyURLSearchParams input', () => { + expect.assertions(1) + const searchParams = new URLSearchParams({ lock: '0x123', network: '1' }) + const result = getPaywallConfigFromQuery(searchParams) + expect(result).toEqual({ + title: 'Unlock Protocol', + network: 1, + locks: { + '0x123': {}, + }, + }) }) }) diff --git a/unlock-app/src/components/interface/checkout/index.tsx b/unlock-app/src/components/interface/checkout/index.tsx index 6ee6295de3a..68379b4d1a2 100644 --- a/unlock-app/src/components/interface/checkout/index.tsx +++ b/unlock-app/src/components/interface/checkout/index.tsx @@ -1,3 +1,5 @@ +'use client' + import { useSearchParams } from 'next/navigation' import { useEffect } from 'react' import { useAuthenticate } from '~/hooks/useAuthenticate' diff --git a/unlock-app/src/utils/paywallConfig.ts b/unlock-app/src/utils/paywallConfig.ts index b2d2fe3a22e..f86a9d267a3 100644 --- a/unlock-app/src/utils/paywallConfig.ts +++ b/unlock-app/src/utils/paywallConfig.ts @@ -1,41 +1,83 @@ import { PaywallConfigType } from '@unlock-protocol/core' import { isValidPaywallConfig } from './checkoutValidators' +import { ReadonlyURLSearchParams } from 'next/navigation' +/** + * Extracts and validates the PaywallConfig from the query parameters. + * + * This utility handles two types of input: + * 1. A full PaywallConfig object passed as a JSON string in the 'paywallConfig' parameter. + * 2. A simplified configuration using 'lock', 'title', and 'network' parameters. + * + * It performs the following steps: + * 1. Normalizes the input to a consistent object format. + * 2. Attempts to parse and validate a full PaywallConfig if present. + * 3. Constructs a simple PaywallConfig from individual parameters if no full config is found. + * 4. Validates the resulting configuration. + * + * @param query - The URL query parameters as a Record or ReadonlyURLSearchParams + * @returns The validated PaywallConfigType or undefined if invalid + */ export function getPaywallConfigFromQuery( - query: Record + query: Record | ReadonlyURLSearchParams ): PaywallConfigType | undefined { - if (typeof query.paywallConfig === 'string') { - const rawConfig = query.paywallConfig - const decodedConfig = decodeURIComponent(rawConfig) + let queryObj: Record = {} + + // Convert ReadonlyURLSearchParams to Record if necessary + if (query instanceof URLSearchParams) { + query.forEach((value, key) => { + // If the key already exists, convert the value to an array + if (queryObj[key]) { + if (Array.isArray(queryObj[key])) { + queryObj[key].push(value) + } else { + queryObj[key] = [queryObj[key], value] + } + } else { + queryObj[key] = value + } + }) + } else { + queryObj = query + } + + // Attempt to parse and validate a full PaywallConfig + if (typeof queryObj.paywallConfig === 'string') { + const rawConfig = queryObj.paywallConfig + const decodedConfig = rawConfig let parsedConfig: any try { parsedConfig = JSON.parse(decodedConfig) - parsedConfig.minRecipients = parsedConfig?.minRecipients || 1 - parsedConfig.maxRecipients = parsedConfig?.maxRecipients || 1 + // Use nullish coalescing operator to preserve null values + parsedConfig.minRecipients = parsedConfig?.minRecipients ?? 1 + parsedConfig.maxRecipients = parsedConfig?.maxRecipients ?? 1 } catch (e) { - console.error( - 'paywall config in URL not valid JSON, continuing with undefined' - ) return undefined } if (isValidPaywallConfig(parsedConfig)) { return parsedConfig as PaywallConfigType } - console.error( - 'paywall config in URL does not pass validation, continuing with undefined' - ) return undefined } - if (typeof query.lock === 'string') { + + // Construct a simple PaywallConfig from individual parameters + if (typeof queryObj.lock === 'string') { + const lock = queryObj.lock + const title = queryObj.title || 'Unlock Protocol' + const network = Number(queryObj.network) + return { - title: query.title || 'Unlock Protocol', - network: Number(query.network), + title, + network, locks: { - [query.lock]: {}, + [lock]: {}, }, } } + + // No valid configuration found + return undefined }