diff --git a/packages/wallet/backend/package.json b/packages/wallet/backend/package.json index 7a8190ddf..bf854db9e 100644 --- a/packages/wallet/backend/package.json +++ b/packages/wallet/backend/package.json @@ -26,6 +26,7 @@ "graphql-request": "^6.1.0", "hash-wasm": "^4.12.0", "helmet": "^7.2.0", + "i18n-iso-countries": "^7.14.0", "ioredis": "^5.8.0", "iron-session": "^8.0.4", "json-canonicalize": "^1.2.0", diff --git a/packages/wallet/backend/src/app.ts b/packages/wallet/backend/src/app.ts index dd6158d6e..4e710f237 100644 --- a/packages/wallet/backend/src/app.ts +++ b/packages/wallet/backend/src/app.ts @@ -429,6 +429,9 @@ export class App { cardController.closeCard ) + //Terminal + router.post('/terminals/validation', terminalController.validation) + // Interledger cards router.get('/cards', isAuth, interledgerCardController.list) diff --git a/packages/wallet/backend/src/terminal/controller.ts b/packages/wallet/backend/src/terminal/controller.ts index 1f9cf4fa7..ab9107d18 100644 --- a/packages/wallet/backend/src/terminal/controller.ts +++ b/packages/wallet/backend/src/terminal/controller.ts @@ -1,9 +1,15 @@ import { Request, Response, NextFunction } from 'express' import { TerminalService } from './service' import { toSuccessResponse } from '@shared/backend' +import { requestSchema } from './validation' +import { WalletAddressService } from '@/walletAddress/service' +import { validate } from '@/shared/validate' export class TerminalController { - constructor(private terminalService: TerminalService) {} + constructor( + private terminalService: TerminalService, + private walletAddressService: WalletAddressService + ) {} getOnboardingFormDefinition = async ( _req: Request, @@ -18,4 +24,37 @@ export class TerminalController { next(error) } } + + validation = async (req: Request, res: Response, next: NextFunction) => { + try { + const formDefinition = + await this.terminalService.getOnboardingFormDefinition() + const schema = await requestSchema(formDefinition) + const { body } = await validate(schema, req) + + // check if walletAddress exists + const paymentPointerField = formDefinition.find( + (value) => value.validation?.format === 'payment-pointer' + ) + + if (paymentPointerField) { + const checkWalletAddress = await this.walletAddressService.getByUrl( + req.body[paymentPointerField.key] + ) + if (!checkWalletAddress) { + return res.status(400).json({ + valid: false, + error: { + field: paymentPointerField.key, + message: 'Wallet Address not found!' + } + }) + } + } + + res.status(200).json({ valid: true, response: body }) + } catch (error) { + next(error) + } + } } diff --git a/packages/wallet/backend/src/terminal/model.ts b/packages/wallet/backend/src/terminal/model.ts index ec30d2a9e..6a6e2b03c 100644 --- a/packages/wallet/backend/src/terminal/model.ts +++ b/packages/wallet/backend/src/terminal/model.ts @@ -1,7 +1,7 @@ import { BaseModel } from '@shared/backend' import { Model } from 'objection' -interface Validation { +export interface Validation { minLength?: number maxLength?: number pattern?: string diff --git a/packages/wallet/backend/src/terminal/validation.ts b/packages/wallet/backend/src/terminal/validation.ts new file mode 100644 index 000000000..7145f5f59 --- /dev/null +++ b/packages/wallet/backend/src/terminal/validation.ts @@ -0,0 +1,174 @@ +import { z } from 'zod' +import countries from 'i18n-iso-countries' +import { FieldDefinitions, Validation } from './model' + +export async function requestSchema(fields: FieldDefinitions[]) { + return z.object({ + body: await buildSchema(fields) + }) +} + +function stringValidation( + schema: z.ZodString, + validation?: Validation, + fieldName?: string +): z.ZodTypeAny { + if (!validation) { + return schema + } + if (validation.minLength) { + schema = schema.min(validation.minLength, { + message: `${fieldName} must be at least ${validation.minLength} characters` + }) + } + if (validation.maxLength) { + schema = schema.max(validation.maxLength, { + message: `${fieldName} must be at most ${validation.maxLength} characters` + }) + } + if (validation.pattern) { + schema = schema.regex(new RegExp(validation.pattern), { + message: `${fieldName} has an invalid format, expected - ${new RegExp(validation.pattern)}` + }) + } + + return schema +} + +export async function buildSchema(fields: FieldDefinitions[]) { + const schemaShape: Record = {} + for (const field of fields) { + let schema: z.ZodTypeAny + + const optionValues = field.options + ? field.options?.map((option) => option.value) + : undefined + + switch (field.type) { + case 'text': + schema = stringValidation( + z.string({ required_error: `${field.key} is required` }), + field.validation, + field.key + ) + switch (field.validation?.format) { + case 'payment-pointer': + schema = z + .string() + .trim() + .url() + .refine((value) => value.startsWith('https://'), { + message: + 'PAYMENT_POINTER must be a URL starting with https:// instead of the classic "$" format' + }) + + break + + case 'iso-country': + schema = z + .string({ required_error: `${field.key} is required` }) + .refine( + (value) => { + const isValid = countries.isValid(value) + return isValid + }, + { + message: `${field.key} does not contain a valid country-code` + } + ) + } + + break + + case 'email': + schema = stringValidation( + z.string({ required_error: `${field.key} is required` }).email(), + field.validation, + field.key + ) + + break + + case 'number': + schema = z.number({ + required_error: `${field.key} is required` + }) + + if (field.validation?.min !== undefined) { + schema = (schema as z.ZodNumber).min(field.validation.min as number, { + message: `${field.key} must be min ${field.validation.min}` + }) + } + + if (field.validation?.max !== undefined) { + schema = (schema as z.ZodNumber).max(field.validation.max as number, { + message: `${field.key} must be max ${field.validation.max}` + }) + } + + break + + case 'tel': + schema = stringValidation( + z + .string({ required_error: `${field.key} is required` }) + .regex(/^\+?[0-9]+$/, 'Invalid phone number'), + field.validation, + field.key + ) + + break + + case 'checkbox': + schema = z + .boolean({ required_error: `${field.key} is required` }) + .refine((value) => value === field.validation?.mustEqual, { + message: `${field.key} must be ${field.validation?.mustEqual}` + }) + + break + + case 'date': + schema = z.coerce.date({ required_error: `${field.key} is required` }) + + if (field.validation?.min !== undefined) { + const minDate = new Date(field.validation.min) + schema = schema.refine((date) => date >= minDate, { + message: `Date must be equal or bigger than ${field.validation.min}` + }) + } + + if (field.validation?.max !== undefined) { + const maxDate = new Date(field.validation.max) + + schema = schema.refine((date) => date <= maxDate, { + message: `Date must be equal or lower than ${field.validation.max}` + }) + } + + break + + case 'select': + schema = z + .string({ required_error: `${field.key} is required` }) + .refine((value) => optionValues?.includes(value), { + message: `Invalid value for ${field.key}` + }) + + break + + default: + schema = z.string().refine(() => {}, { + message: `something went wrong with this key - ${field.key}` + }) + } + + if (!field.required) { + schema = schema.optional() + } + + schemaShape[field.key] = schema + } + + return z.object(schemaShape) +} diff --git a/packages/wallet/backend/tests/terminal/controller.test.ts b/packages/wallet/backend/tests/terminal/controller.test.ts index 1e3013d08..6655b72fe 100644 --- a/packages/wallet/backend/tests/terminal/controller.test.ts +++ b/packages/wallet/backend/tests/terminal/controller.test.ts @@ -8,11 +8,15 @@ import { Request, Response } from 'express' import { TerminalController } from '@/terminal/controller' import { TerminalService } from '@/terminal/service' import { FieldDefinitions } from '@/terminal/model' +import { WalletAddressService } from '@/walletAddress/service' describe('Terminal Controller', () => { const mockTerminalService = { getOnboardingFormDefinition: jest.fn() } + const mockWalletAddressService = { + getByUrl: jest.fn() + } let terminalController: TerminalController let req: MockRequest @@ -21,7 +25,8 @@ describe('Terminal Controller', () => { beforeEach(() => { terminalController = new TerminalController( - mockTerminalService as unknown as TerminalService + mockTerminalService as unknown as TerminalService, + mockWalletAddressService as unknown as WalletAddressService ) req = createRequest() res = createResponse() @@ -74,4 +79,298 @@ describe('Terminal Controller', () => { expect(next).toHaveBeenCalled() }) + + describe('validation', () => { + it('should return true when the email format is valid', async () => { + const formDefinition = [ + { + key: 'mockContactEmail', + label: 'Mock contact email', + description: 'We use this to send onboarding confirmation.', + type: 'email', + required: true, + placeholder: 'me@interledger.org', + order: 2, + validation: { + maxLength: 255, + format: 'email' + } + } + ] as unknown as FieldDefinitions[] + + mockTerminalService.getOnboardingFormDefinition.mockResolvedValue( + formDefinition + ) + + req.body = { + mockContactEmail: 'test@example.com' + } + + await terminalController.validation(req, res, next) + + expect(mockTerminalService.getOnboardingFormDefinition).toHaveBeenCalled() + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toMatchObject({ + valid: true, + response: { + mockContactEmail: 'test@example.com' + } + }) + expect(next).not.toHaveBeenCalled() + }) + + it('should call next with a validation error when a required field is missing', async () => { + const formDefinition = [ + { + key: 'mockContactEmail', + label: 'Mock contact email', + type: 'email', + required: true, + order: 1 + } + ] as unknown as FieldDefinitions[] + + mockTerminalService.getOnboardingFormDefinition.mockResolvedValue( + formDefinition + ) + + req.body = {} + + await terminalController.validation(req, res, next) + + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Invalid input', + errors: { mockContactEmail: 'mockContactEmail is required' } + }) + ) + }) + + it('should accept a numeric value of exactly 0 when min is 0', async () => { + const formDefinition = [ + { + key: 'mockMinNumber', + label: 'Mock Min Number', + type: 'number', + required: true, + order: 1, + validation: { min: 0 } + } + ] as unknown as FieldDefinitions[] + + mockTerminalService.getOnboardingFormDefinition.mockResolvedValue( + formDefinition + ) + + req.body = { mockMinNumber: 0 } + + await terminalController.validation(req, res, next) + + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toMatchObject({ + valid: true, + response: { + mockMinNumber: 0 + } + }) + expect(next).not.toHaveBeenCalled() + }) + + it('should reject a select value that is not one of the field options', async () => { + const formDefinition = [ + { + key: 'mockMerchantCategoryCode', + label: 'Mock merchant category code', + type: 'select', + required: true, + order: 1, + options: [ + { value: '5411', label: 'Grocery stores' }, + { value: '5412', label: 'Eating places / restaurants' } + ] + } + ] as unknown as FieldDefinitions[] + + mockTerminalService.getOnboardingFormDefinition.mockResolvedValue( + formDefinition + ) + + req.body = { mockMerchantCategoryCode: '5432' } + + await terminalController.validation(req, res, next) + + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Invalid input', + errors: { + mockMerchantCategoryCode: + 'Invalid value for mockMerchantCategoryCode' + } + }) + ) + }) + + it('should approve a select value that is one of the field options', async () => { + const formDefinition = [ + { + key: 'mockMerchantCategoryCode', + label: 'Mock merchant category code', + type: 'select', + required: true, + order: 1, + options: [ + { value: '5411', label: 'Grocery stores' }, + { value: '5412', label: 'Eating places / restaurants' } + ] + } + ] as unknown as FieldDefinitions[] + + mockTerminalService.getOnboardingFormDefinition.mockResolvedValue( + formDefinition + ) + + req.body = { mockMerchantCategoryCode: '5412' } + + await terminalController.validation(req, res, next) + + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toMatchObject({ + valid: true, + response: { + mockMerchantCategoryCode: '5412' + } + }) + expect(next).not.toHaveBeenCalled() + }) + + it('should reject a checkbox value that does not match mustEqual', async () => { + const formDefinition = [ + { + key: 'mockAcceptTerms', + label: 'Mock accept Terms', + type: 'checkbox', + required: true, + order: 1, + validation: { mustEqual: true } + } + ] as unknown as FieldDefinitions[] + + mockTerminalService.getOnboardingFormDefinition.mockResolvedValue( + formDefinition + ) + + req.body = { mockAcceptTerms: false } + + await terminalController.validation(req, res, next) + + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Invalid input', + errors: { + mockAcceptTerms: 'mockAcceptTerms must be true' + } + }) + ) + }) + + it('should return 200 when the wallet address format is valid', async () => { + const formDefinition = [ + { + key: 'mockWalletAddress', + label: 'Mock wallet address', + type: 'text', + required: true, + order: 1, + validation: { format: 'payment-pointer' } + } + ] as unknown as FieldDefinitions[] + + mockTerminalService.getOnboardingFormDefinition.mockResolvedValue( + formDefinition + ) + mockWalletAddressService.getByUrl.mockResolvedValue({ + id: 'mocked-wallet-address' + }) + + req.body = { + mockWalletAddress: 'https://rafiki-backend.testnet.test/mockAddress' + } + + await terminalController.validation(req, res, next) + + expect(mockWalletAddressService.getByUrl).toHaveBeenCalledWith( + 'https://rafiki-backend.testnet.test/mockAddress' + ) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toMatchObject({ + valid: true, + response: { + mockWalletAddress: 'https://rafiki-backend.testnet.test/mockAddress' + } + }) + expect(next).not.toHaveBeenCalled() + }) + + it('should fail when the phone number type is invalid', async () => { + const formDefinition = [ + { + key: 'mockPhoneNumber', + label: 'Mock Phone Number', + type: 'tel', + required: true, + order: 1, + validation: { minLength: 10 } + } + ] as unknown as FieldDefinitions[] + + mockTerminalService.getOnboardingFormDefinition.mockResolvedValue( + formDefinition + ) + + req.body = { mockPhoneNumber: '+4123invalidPhoneNumber' } + + await terminalController.validation(req, res, next) + + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Invalid input', + errors: { + mockPhoneNumber: 'Invalid phone number' + } + }) + ) + }) + + it('should return true when the country code format is valid', async () => { + const formDefinition = [ + { + key: 'mockCountryCode', + label: 'Mock Country Code', + type: 'text', + required: true, + order: 1, + validation: { + format: 'iso-country' + } + } + ] as unknown as FieldDefinitions[] + + mockTerminalService.getOnboardingFormDefinition.mockResolvedValue( + formDefinition + ) + + req.body = { mockCountryCode: 'USA' } + + await terminalController.validation(req, res, next) + + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toMatchObject({ + valid: true, + response: { + mockCountryCode: 'USA' + } + }) + expect(next).not.toHaveBeenCalled() + }) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05f59de17..fd92005db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,9 @@ importers: helmet: specifier: ^7.2.0 version: 7.2.0 + i18n-iso-countries: + specifier: ^7.14.0 + version: 7.14.0 ioredis: specifier: ^5.8.0 version: 5.8.0 @@ -3708,6 +3711,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + diacritics@1.3.0: + resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -4477,6 +4483,10 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + i18n-iso-countries@7.14.0: + resolution: {integrity: sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==} + engines: {node: '>= 12'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -10701,6 +10711,8 @@ snapshots: detect-node-es@1.1.0: {} + diacritics@1.3.0: {} + didyoumean@1.2.2: {} diff-sequences@29.6.3: {} @@ -11721,6 +11733,10 @@ snapshots: human-signals@2.1.0: {} + i18n-iso-countries@7.14.0: + dependencies: + diacritics: 1.3.0 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2