diff --git a/.vscode/launch.json b/.vscode/launch.json index b6d8f6c72..8484a32e8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,6 +10,24 @@ "localRoot": "${workspaceFolder}/packages/wallet/backend", "remoteRoot": "/home/testnet/packages/wallet/backend", "protocol": "inspector" + }, + { + // debugs jest test file in wallet backend + "type": "node", + "request": "launch", + "name": "Wallet Backend-Jest Test File", + "program": "${workspaceFolder}/packages/wallet/backend/node_modules/jest/bin/jest.js", + "args": [ + "--runTestsByPath", + "${relativeFile}", + "--config", + "packages/wallet/backend/jest.config.json" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "windows": { + "program": "${workspaceFolder}/packages/wallet/backend/node_modules/jest/bin/jest.js" + } } ] } diff --git a/package.json b/package.json index 13d09c4bd..62df82fdb 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "localenv:start:lite": "cross-env DEV_MODE=lite pnpm compose up -d --build", "localenv:stop": "pnpm compose down", "preinstall": "npx only-allow pnpm", - "prettier:write": "prettier --config '.prettierrc.js' --write .", - "prettier:check": "prettier --config '.prettierrc.js' --check .", + "prettier:write": "prettier --config \".prettierrc.js\" --write .", + "prettier:check": "prettier --config \".prettierrc.js\" --check .", "prod": "pnpm compose:prod up -d --build", "prod:down": "pnpm compose:prod down", "wallet:backend": "pnpm --filter @wallet/backend --", diff --git a/packages/wallet/backend/src/rafiki/controller.ts b/packages/wallet/backend/src/rafiki/controller.ts index 8ddd3c719..54a3cb824 100644 --- a/packages/wallet/backend/src/rafiki/controller.ts +++ b/packages/wallet/backend/src/rafiki/controller.ts @@ -4,7 +4,7 @@ import { RatesService } from '@/rates/service' import { ratesSchema } from '@/rates/validation' import { validate } from '@/shared/validate' import { RafikiService } from './service' -import { webhookSchema } from './validation' +import { webhookBodySchema } from './validation' import { RatesResponse } from '@wallet/shared' interface IRafikiController { getRates: ( @@ -39,7 +39,7 @@ export class RafikiController implements IRafikiController { onWebHook = async (req: Request, res: Response, next: NextFunction) => { try { - const wh = await validate(webhookSchema, req) + const wh = await validate(webhookBodySchema, req) await this.rafikiService.onWebHook(wh.body) res.status(200).send() diff --git a/packages/wallet/backend/src/rafiki/service.ts b/packages/wallet/backend/src/rafiki/service.ts index 8dc93e163..9e92e4e7f 100644 --- a/packages/wallet/backend/src/rafiki/service.ts +++ b/packages/wallet/backend/src/rafiki/service.ts @@ -11,6 +11,14 @@ import MessageType from '@/socket/messageType' import { BadRequest } from '@shared/backend' import { GateHubClient } from '@/gatehub/client' import { TransactionTypeEnum } from '@/gatehub/consts' +import { + WebhookType, + incomingPaymentWebhookSchema, + incomingPaymentCompletedWebhookSchema, + outgoingPaymentWebhookSchema, + walletAddressWebhookSchema, + validateInput +} from './validation' export enum EventType { IncomingPaymentCreated = 'incoming_payment.created', @@ -69,8 +77,11 @@ type Fee = { export type Fees = Record +const isValidEventType = (value: string): value is EventType => { + return Object.values(EventType).includes(value as EventType) +} interface IRafikiService { - onWebHook: (wh: WebHook) => Promise + onWebHook: (wh: WebhookType) => Promise } export class RafikiService implements IRafikiService { @@ -84,12 +95,19 @@ export class RafikiService implements IRafikiService { private walletAddressService: WalletAddressService ) {} - public async onWebHook(wh: WebHook): Promise { + public async onWebHook(wh: WebhookType): Promise { this.logger.info( `received webhook of type : ${wh.type} for : ${ - wh.type === EventType.WalletAddressNotFound ? '' : `${wh.data.id}}` + wh.type === EventType.WalletAddressNotFound ? '' : `${wh.id}}` }` ) + if (!isValidEventType(wh.type)) { + throw new BadRequest(`unknown event type, ${wh.type}`) + } + const isValid = await this.isValidInput(wh) + if (!isValid) { + throw new BadRequest(`Invalid Input for ${wh.type}`) + } switch (wh.type) { case EventType.OutgoingPaymentCreated: await this.handleOutgoingPaymentCreated(wh) @@ -112,11 +130,35 @@ export class RafikiService implements IRafikiService { case EventType.WalletAddressNotFound: this.logger.warn(`${EventType.WalletAddressNotFound} received`) break - default: - throw new BadRequest(`unknown event type, ${wh.type}`) } } + private async isValidInput(wh: WebhookType) { + let validInput = false + + switch (wh.type) { + case EventType.OutgoingPaymentCreated: + case EventType.OutgoingPaymentCompleted: + case EventType.OutgoingPaymentFailed: + validInput = await validateInput(outgoingPaymentWebhookSchema, wh) + break + case EventType.IncomingPaymentCompleted: + validInput = await validateInput( + incomingPaymentCompletedWebhookSchema, + wh + ) + break + case EventType.IncomingPaymentCreated: + case EventType.IncomingPaymentExpired: + validInput = await validateInput(incomingPaymentWebhookSchema, wh) + break + case EventType.WalletAddressNotFound: + validInput = await validateInput(walletAddressWebhookSchema, wh) + break + } + return validInput + } + private parseAmount(amount: AmountJSON): Amount { return { ...amount, value: BigInt(amount.value) } } @@ -126,7 +168,8 @@ export class RafikiService implements IRafikiService { if ( [ EventType.OutgoingPaymentCreated, - EventType.OutgoingPaymentCompleted + EventType.OutgoingPaymentCompleted, + EventType.OutgoingPaymentFailed ].includes(wh.type) ) { amount = this.parseAmount(wh.data.debitAmount as AmountJSON) diff --git a/packages/wallet/backend/src/rafiki/validation.ts b/packages/wallet/backend/src/rafiki/validation.ts index 82542c7f6..95e8058aa 100644 --- a/packages/wallet/backend/src/rafiki/validation.ts +++ b/packages/wallet/backend/src/rafiki/validation.ts @@ -1,14 +1,6 @@ import { z } from 'zod' import { EventType, PaymentType } from './service' -export const webhookSchema = z.object({ - body: z.object({ - id: z.string({ required_error: 'id is required' }), - type: z.nativeEnum(EventType), - data: z.record(z.string(), z.any()) - }) -}) - const quoteAmountSchema = z.object({ value: z.coerce.bigint(), assetCode: z.string(), @@ -31,3 +23,132 @@ export const quoteSchema = z.object({ expiresAt: z.string() }) }) + +const amountSchema = z.object({ + value: z.coerce.number(), + assetCode: z.string(), + assetScale: z.number() +}) + +const incomingPaymentCompletedSchema = z.object({ + id: z.string(), + walletAddressId: z.string(), + createdAt: z.string(), + expiresAt: z.string(), + incomingAmount: amountSchema, + receivedAmount: amountSchema, + completed: z.boolean(), + updatedAt: z.string(), + metadata: z.object({ + description: z.string() + }) +}) +const incomingPaymentSchema = z.object({ + id: z.string(), + walletAddressId: z.string(), + createdAt: z.string(), + expiresAt: z.string(), + receivedAmount: amountSchema, + completed: z.boolean(), + updatedAt: z.string(), + metadata: z.object({ + description: z.string() + }) +}) +const outgoingPaymentSchema = z.object({ + id: z.string(), + walletAddressId: z.string(), + client: z.string(), + state: z.string(), + receiver: z.string(), + debitAmount: amountSchema, + receiveAmount: amountSchema, + sentAmount: amountSchema, + stateAttempts: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + balance: z.string(), + metadata: z.object({ + description: z.string() + }) +}) +const outgoingPaymentCreatedSchema = z.object({ + id: z.string(), + walletAddressId: z.string(), + client: z.string(), + state: z.string(), + receiver: z.string(), + debitAmount: amountSchema, + receiveAmount: amountSchema, + sentAmount: amountSchema, + stateAttempts: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + balance: z.string(), + metadata: z.object({ + description: z.string() + }) +}) +export const incomingPaymentCompletedWebhookSchema = z.object({ + id: z.string({ required_error: 'id is required' }), + type: z.nativeEnum(EventType), + data: incomingPaymentCompletedSchema +}) +export const incomingPaymentWebhookSchema = z.object({ + id: z.string({ required_error: 'id is required' }), + type: z.nativeEnum(EventType), + data: incomingPaymentSchema +}) +export const outgoingPaymentCreatedWebhookSchema = z.object({ + id: z.string({ required_error: 'id is required' }), + type: z.nativeEnum(EventType), + data: outgoingPaymentCreatedSchema +}) +export const outgoingPaymentWebhookSchema = z.object({ + id: z.string(), + type: z.nativeEnum(EventType), + data: outgoingPaymentSchema +}) +export const walletAddressWebhookSchema = z.object({ + id: z.string(), + type: z.nativeEnum(EventType), + data: z.object({ + walletAddressUrl: z.string() + }) +}) +export const webhookSchema = z.union([ + incomingPaymentCompletedWebhookSchema, + incomingPaymentWebhookSchema, + outgoingPaymentWebhookSchema, + walletAddressWebhookSchema +]) +export const incomingPaymentWebhookBodySchema = z.object({ + body: incomingPaymentWebhookSchema +}) +export const webhookBodySchema = z.object({ + body: webhookSchema +}) +export type WebhookType = z.infer + +export async function validateInput( + schema: Z, + input: WebhookType +): Promise { + try { + const res = await schema.safeParseAsync(input) + if (!res.success) { + const errors: Record = {} + res.error.issues.forEach((i) => { + if (i.path.length > 1) { + errors[i.path[1]] = i.message + } else { + errors[i.path[0]] = i.message + } + }) + return false + } + } catch (error) { + return false + } + return true +} diff --git a/packages/wallet/backend/tests/gatehub/controller.test.ts b/packages/wallet/backend/tests/gatehub/controller.test.ts new file mode 100644 index 000000000..b1187438a --- /dev/null +++ b/packages/wallet/backend/tests/gatehub/controller.test.ts @@ -0,0 +1,169 @@ +import { env } from '@/config/env' +import { Cradle, createContainer } from '@/createContainer' +import { AuthService } from '@/auth/service' +import { NextFunction, Request, Response } from 'express' +import { createApp, TestApp } from '@/tests/app' +import { + MockRequest, + MockResponse, + createRequest, + createResponse +} from 'node-mocks-http' +import { mockLogInRequest } from '@/tests/mocks' +import { applyMiddleware } from '@/tests/utils' +import { withSession } from '@/middleware/withSession' +import { User } from '@/user/model' +import { createUser } from '@/tests/helpers' +import { GateHubController } from '@/gatehub/controller' +import { truncateTables } from '@shared/backend/tests' +import { AwilixContainer } from 'awilix' +import { Knex } from 'knex' + +describe('GateHub Controller', () => { + let bindings: AwilixContainer + let appContainer: TestApp + let knex: Knex + let req: MockRequest + let res: MockResponse + let authService: AuthService + let gateHubController: GateHubController + + const mockGateHubService = { + getIframeUrl: jest.fn(), + handleWebhook: jest.fn(), + addUserToGateway: jest.fn() + } + + const args = mockLogInRequest().body + + const next = jest.fn() as unknown as NextFunction + + const createReqRes = async () => { + res = createResponse() + req = createRequest() + + await applyMiddleware(withSession, req, res) + const { user, session } = await authService.authorize(args) + req.session.id = session.id + req.session.user = { + id: user.id, + email: user.email, + needsWallet: !user.gateHubUserId, + needsIDProof: !user.kycVerified + } + + await User.query().patchAndFetchById(user.id, { gateHubUserId: 'mocked' }) + } + + beforeAll(async () => { + bindings = await createContainer(env) + appContainer = await createApp(bindings) + knex = appContainer.knex + authService = await bindings.resolve('authService') + gateHubController = await bindings.resolve('gateHubController') + }) + + beforeEach(async (): Promise => { + Reflect.set(gateHubController, 'gateHubService', mockGateHubService) + await createUser({ ...args, isEmailVerified: true }) + await createReqRes() + }) + + afterEach(async (): Promise => { + await truncateTables(knex) + jest.resetAllMocks() + }) + + afterAll(async (): Promise => { + await appContainer.stop() + await knex.destroy() + }) + + describe('GetIframeUrl', () => { + it('should call method GetIframeUrl() successfully', async () => { + req.params = { + type: 'withdrawal' + } + const mockedIframeUrl = 'URL' + mockGateHubService.getIframeUrl.mockResolvedValue(mockedIframeUrl) + + await gateHubController.getIframeUrl(req, res, next) + + expect(mockGateHubService.getIframeUrl).toHaveBeenCalledWith( + 'withdrawal', + req.session.user.id + ) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toMatchObject({ + message: 'SUCCESS', + result: { + url: mockedIframeUrl + } + }) + }) + }) + + describe('addUserToGateway', () => { + it('should call method gateHubService.addUserToGateway() successfully and have a success result', async () => { + const mockedAddUserToGatewayResponse = { + isApproved: true, + customerId: 'test-customer-123' + } + mockGateHubService.addUserToGateway.mockResolvedValue( + mockedAddUserToGatewayResponse + ) + + await gateHubController.addUserToGateway(req, res, next) + + expect(mockGateHubService.addUserToGateway).toHaveBeenCalledWith( + req.session.user.id + ) + expect(res.statusCode).toBe(200) + expect(req.session.user.needsIDProof).toBe(false) + expect(req.session.user.customerId).toBe('test-customer-123') + expect(res._getJSONData()).toMatchObject({ + message: 'SUCCESS' + }) + }) + + it('should call method gateHubService.addUserToGateway() but return not approved', async () => { + const next = jest.fn() + const mockedAddUserToGatewayResponse = { + isApproved: false + } + mockGateHubService.addUserToGateway.mockResolvedValue( + mockedAddUserToGatewayResponse + ) + + await gateHubController.addUserToGateway(req, res, next) + + expect(mockGateHubService.addUserToGateway).toHaveBeenCalledWith( + req.session.user.id + ) + expect(res.statusCode).toBe(200) + expect(req.session.user.needsIDProof).toBe(true) + expect(req.session.user.customerId).toBeUndefined() + expect(res._getJSONData()).toMatchObject({ + message: 'SUCCESS' + }) + }) + }) + + describe('webhook action tests', () => { + it('should handle webhook successfully when UUID is present', async () => { + const webhookData = { + uuid: 1, + event_type: 'id.verification.accepted', + user_uuid: 'mocked' + } + req.body = webhookData + + await gateHubController.webhook(req, res, next) + + expect(mockGateHubService.handleWebhook).toHaveBeenCalledWith(req.body) + expect(res.statusCode).toBe(200) + expect(res._getData()).toMatch('') + }) + // TO DO more after signature check is implemented + }) +}) diff --git a/packages/wallet/backend/tests/gatehub/service.test.ts b/packages/wallet/backend/tests/gatehub/service.test.ts new file mode 100644 index 000000000..ceb4e32e7 --- /dev/null +++ b/packages/wallet/backend/tests/gatehub/service.test.ts @@ -0,0 +1,454 @@ +import { Cradle, createContainer } from '@/createContainer' +import { env } from '@/config/env' +import { createApp, TestApp } from '@/tests/app' +import { Knex } from 'knex' +import { truncateTables } from '@shared/backend/tests' +import type { AuthService } from '@/auth/service' +import { faker } from '@faker-js/faker' +import { AwilixContainer } from 'awilix' +import { GateHubService } from '@/gatehub/service' +import { loginUser } from '../utils' +import { User } from '@/user/model' +import { ICardTransactionWebhookData, IWebhookData } from '@/gatehub/types' +import { GateHubClient } from '@/gatehub/client' +import { EmailService } from '@/email/service' +import { Account } from '@/account/model' +import { WalletAddress } from '@/walletAddress/model' +import { Transaction } from '@/transaction/model' +import { mockedListAssets } from '../mocks' + +describe('GateHub Service', (): void => { + let bindings: AwilixContainer + let appContainer: TestApp + let knex: Knex + let authService: AuthService + let gateHubService: GateHubService + let user: User + + const mockAccountService = { + createDefaultAccount: jest.fn() + } + + const mockWalletAddressService = { + create: jest.fn() + } + + const mockEmailService = { + sendKYCVerifiedEmail: jest.fn(), + sendActionRequiredEmail: jest.fn(), + sendUserRejectedEmail: jest.fn() + } + + const mockGateHubClient = { + getIframeUrl: jest.fn(), + handleWebhook: jest.fn(), + addUserToGateway: jest.fn(), + createManagedUser: jest.fn(), + createWallet: jest.fn(), + connectUserToGateway: jest.fn(), + getWalletBalance: jest.fn(), + getUserState: jest.fn(), + isProduction: false, + getManagedUsers: jest.fn(), + getCardsByCustomer: jest.fn(), + createCustomer: jest.fn() + } + + const mockLogger = { + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn() + } + + const createMockWebhookData = ( + mockData?: Partial + ): IWebhookData => ({ + uuid: faker.string.uuid(), + timestamp: Date.now().toString(), + user_uuid: mockData?.user_uuid ?? 'mocked', + environment: 'sandbox', + event_type: mockData?.event_type ?? 'id.verification.accepted', + data: mockData?.data ?? {} + }) + + beforeAll(async (): Promise => { + bindings = await createContainer(env) + appContainer = await createApp(bindings) + knex = appContainer.knex + authService = await bindings.resolve('authService') + gateHubService = await bindings.resolve('gateHubService') + }) + + afterEach(async (): Promise => { + await truncateTables(knex) + jest.resetAllMocks() + }) + + afterAll(async (): Promise => { + await appContainer.stop() + await knex.destroy() + }) + + beforeEach(async (): Promise => { + const extraUserArgs = { + isEmailVerified: true, + gateHubUserId: 'mocked', + firstName: 'John', + lastName: 'Doe' + } + + const resp = await loginUser({ + authService, + extraUserArgs + }) + user = resp.user + + Reflect.set( + gateHubService, + 'gateHubClient', + mockGateHubClient as unknown as GateHubClient + ) + Reflect.set( + gateHubService, + 'emailService', + mockEmailService as unknown as EmailService + ) + Reflect.set(gateHubService, 'accountService', mockAccountService) + Reflect.set( + gateHubService, + 'walletAddressService', + mockWalletAddressService + ) + Reflect.set(gateHubService, 'logger', mockLogger) + + mockAccountService.createDefaultAccount.mockReturnValue({ + id: faker.string.uuid(), + userId: user.id, + assetCode: 'EUR', + name: 'EUR Account', + gateHubWalletId: faker.string.uuid() + }) + + mockWalletAddressService.create.mockReturnValue({ + id: faker.string.uuid(), + accountId: faker.string.uuid(), + userId: user.id, + url: 'test-wallet', + isCard: false + }) + + mockGateHubClient.getUserState.mockReturnValue({ + profile: { + last_name: user.lastName, + first_name: user.firstName, + address_country_code: 'US', + address_city: 'Rehoboth' + } + }) + }) + + describe('Get Iframe Url', () => { + it('should return Iframe Url ', async () => { + const mockedIframeUrl = 'URL' + mockGateHubClient.getIframeUrl.mockReturnValue(mockedIframeUrl) + + const result = await gateHubService.getIframeUrl('withdrawal', user.id) + expect(result).toMatch(mockedIframeUrl) + }) + + it('should return NotFound if no user found', async () => { + await expect( + gateHubService.getIframeUrl('withdrawal', faker.string.uuid()) + ).rejects.toThrowError(/Not Found/) + }) + it('should return NotFound if gateHubUserId not found', async () => { + await User.query().findById(user.id).patch({ + gateHubUserId: '' + }) + await expect( + gateHubService.getIframeUrl('withdrawal', user.id) + ).rejects.toThrowError(/Not Found/) + }) + }) + + describe('handle Webhook', () => { + describe('KYC Verification Events', () => { + it('should mark User As Verified and send email in production', async () => { + mockGateHubClient.isProduction = true + await gateHubService.handleWebhook(createMockWebhookData()) + + const userData = await User.query().findById(user.id) + expect(userData?.kycVerified).toBe(true) + expect(mockEmailService.sendKYCVerifiedEmail).toHaveBeenCalledWith( + user.email + ) + }) + + it('should handle already verified users properly', async () => { + await User.query().findById(user.id).patch({ + kycVerified: true, + lastName: 'Existing' + }) + await gateHubService.handleWebhook(createMockWebhookData()) + + const updatedUser = await User.query().findById(user.id) + expect(updatedUser?.kycVerified).toBe(true) + expect(mockGateHubClient.connectUserToGateway).not.toHaveBeenCalled() + }) + + it('should handle action_required webhook and send email', async () => { + await gateHubService.handleWebhook( + createMockWebhookData({ + event_type: 'id.verification.action_required', + data: { message: 'additional documents needed' } + }) + ) + + const userData = await User.query().findById(user.id) + expect(userData?.kycVerified).toBe(false) + expect(mockEmailService.sendActionRequiredEmail).toHaveBeenCalledWith( + user.email, + 'additional documents needed' + ) + }) + + it('sending a different verification event should not mark User As Verified', async () => { + await gateHubService.handleWebhook( + createMockWebhookData({ event_type: 'id.verification.rejected' }) + ) + const userData = await User.query().findById(user.id) + expect(userData?.kycVerified).toBe(false) + }) + + it('sending a wrong gateHubUserId will throw a User Not found exception', async () => { + await expect( + gateHubService.handleWebhook( + createMockWebhookData({ user_uuid: faker.string.uuid() }) + ) + ).rejects.toThrowError(/User not found/) + expect(mockLogger.error).toHaveBeenCalled() + }) + + it('should handle document notice warnings', async () => { + await gateHubService.handleWebhook( + createMockWebhookData({ event_type: 'id.document_notice.warning' }) + ) + + const userData = await User.query().findById(user.id) + expect(userData?.isDocumentUpdateRequired).toBe(true) + }) + }) + + describe('Card Transaction Events', () => { + let account: Account + let walletAddress: WalletAddress + const createMockCardData = ( + amount = '10.50' + ): ICardTransactionWebhookData => ({ + authorizationData: { + transactionId: 'tx-123', + billingCurrency: 'EUR', + billingAmount: amount, + id: 0, + ghResponseCode: '', + cardScheme: 0, + type: 0, + createdAt: '', + txStatus: '', + vaultId: 0, + cardId: 0, + refTransactionId: '', + responseCode: null, + transactionAmount: '', + transactionCurrency: '', + terminalId: null, + wallet: 0, + transactionDateTime: '', + processDateTime: null + } + }) + + beforeEach(async () => { + account = await Account.query().insert({ + name: faker.string.alpha(10), + userId: user.id, + assetCode: mockedListAssets[2].code, + assetId: mockedListAssets[2].id, + assetScale: mockedListAssets[2].scale, + gateHubWalletId: 'mocked' + }) + + walletAddress = await WalletAddress.query().insert({ + accountId: account.id, + id: faker.string.uuid(), + url: 'test-wallet', + isCard: true, + publicName: 'Test Wallet' + }) + }) + + it('should process card transaction successfully', async () => { + // set card wallet address + await User.query().findById(user.id).patch({ + cardWalletAddress: walletAddress.url + }) + + await gateHubService.handleWebhook( + createMockWebhookData({ + event_type: 'cards.transaction.authorization', + data: createMockCardData() + }) + ) + + const transaction = await Transaction.query().first() + expect(transaction).toBeTruthy() + expect(transaction?.walletAddressId).toBe(walletAddress.id) + expect(transaction?.accountId).toBe(account.id) + expect(transaction?.paymentId).toBe('tx-123') + expect(transaction?.value).toBe(BigInt(1050)) + }) + + it('should handle missing card wallet address record', async () => { + await User.query().findById(user.id).patch({ + cardWalletAddress: undefined + }) + + await gateHubService.handleWebhook( + createMockWebhookData({ + event_type: 'cards.transaction.authorization', + data: createMockCardData() + }) + ) + + const transaction = await Transaction.query().first() + expect(transaction).toBeFalsy() + expect(mockLogger.warn).toHaveBeenCalled() + }) + }) + }) + + describe('addUserToGateway', () => { + describe('Staging Environment', () => { + it('should set user as KYC verified', async () => { + const mockedConnectUserToGatewayResponse = true + mockGateHubClient.connectUserToGateway.mockReturnValue( + mockedConnectUserToGatewayResponse + ) + const result = await gateHubService.addUserToGateway(user.id) + expect(result.isApproved).toBe(mockedConnectUserToGatewayResponse) + + const userData = await User.query().findById(user.id) + expect(userData?.kycVerified).toBe(true) + }) + + it('should not set user as KYC verified when user not connected to gateway', async () => { + const mockedConnectUserToGatewayResponse = false + mockGateHubClient.connectUserToGateway.mockReturnValue( + mockedConnectUserToGatewayResponse + ) + const result = await gateHubService.addUserToGateway(user.id) + + expect(result.isApproved).toBe(mockedConnectUserToGatewayResponse) + + const userData = await User.query().findById(user.id) + expect(userData?.kycVerified).toBe(false) + }) + + it('should return Not Found if user not found', async () => { + await expect( + gateHubService.addUserToGateway(faker.string.uuid()) + ).rejects.toThrowError(/Not Found/) + }) + + it('should return Not Found if user has no gateHubUserId field set', async () => { + await User.query().findById(user.id).patch({ + gateHubUserId: '' + }) + await expect( + gateHubService.addUserToGateway(user.id) + ).rejects.toThrowError(/Not Found/) + }) + }) + + describe('Sandbox Environment', () => { + beforeEach(() => { + env.NODE_ENV = 'development' + env.GATEHUB_ENV = 'sandbox' + }) + + it('should setupSandboxCustomer successfully', async () => { + const mockedCustomer = { + customers: { + id: 'cust-123', + accounts: [ + { + cards: [ + { + id: 'card-123' + } + ] + } + ] + } + } + + mockGateHubClient.createCustomer.mockResolvedValue(mockedCustomer) + const result = await gateHubService.addUserToGateway(user.id) + + expect(result.customerId).toBe(mockedCustomer.customers.id) + }) + }) + + describe('Production Environment', () => { + beforeEach(() => { + env.NODE_ENV = 'production' + env.GATEHUB_ENV = 'production' + }) + + it('should setupProductionCustomer successfully', async () => { + const mockedManagedUsers = [ + { + id: user.gateHubUserId, + email: user.email, + meta: { + meta: { + customerId: 'cust-123', + paymentPointer: '$ilp.dev/test-wallet' + } + } + } + ] + const mockedCards = [ + { + id: 'card-123', + status: 'Active' + } + ] + + mockGateHubClient.getManagedUsers.mockResolvedValue(mockedManagedUsers) + mockGateHubClient.getCardsByCustomer.mockResolvedValue(mockedCards) + const result = await gateHubService.addUserToGateway(user.id) + + expect(result.customerId).toBe('cust-123') + + const updatedUser = await User.query().findById(user.id) + expect(updatedUser?.customerId).toBe('cust-123') + expect(updatedUser?.cardWalletAddress).toBe('$ilp.dev/test-wallet') + }) + + it('should return undefined if the GateHub user with the specified email is not found', async () => { + mockGateHubClient.getManagedUsers.mockResolvedValue([ + { email: 'user1@example.com' }, + { email: 'user2@example.com' } + ]) + + const result = await gateHubService.addUserToGateway(user.id) + expect(result).toEqual({ + customerId: undefined, + isApproved: undefined + }) + }) + }) + }) +}) diff --git a/packages/wallet/backend/tests/mocks.ts b/packages/wallet/backend/tests/mocks.ts index 7abda7ffb..11e8ab77a 100644 --- a/packages/wallet/backend/tests/mocks.ts +++ b/packages/wallet/backend/tests/mocks.ts @@ -5,7 +5,7 @@ import { PartialModelObject } from 'objection' import { Transaction } from '../src/transaction/model' import { quoteSchema } from '@/quote/validation' import { uuid } from '@/tests/utils' -import { webhookSchema } from '@/rafiki/validation' +import { webhookBodySchema, WebhookType } from '@/rafiki/validation' import { EventType, WebHook } from '@/rafiki/service' import { incomingPaymentSchema, @@ -16,7 +16,7 @@ import { ratesSchema } from '@/rates/validation' export type LogInRequest = z.infer export type GetRatesRequest = z.infer -export type OnWebHook = z.infer +export type OnWebHook = z.infer export type IncomingPaymentCreated = z.infer export type IncomingPaymentRequest = z.infer export type GetPaymentDetailsByUrl = z.infer @@ -85,6 +85,13 @@ export const mockedListAssets = [ id: 'ca1d9728-d38f-47e6-a88e-3bfe9e60438e', scale: 4, withdrawalThreshold: null + }, + { + code: 'EUR', + createdAt: '2023-06-28T14:33:24.888Z', + id: 'da1d9728-e38f-47e6-a88e-5bfe9e60438d', + scale: 2, + withdrawalThreshold: null } ] @@ -235,6 +242,37 @@ export const mockRatesService = { }) } +const mockOutgoingPaymentData = { + id: 'mockedId', + walletAddressId: faker.string.uuid(), + client: faker.string.uuid(), + state: faker.string.uuid(), + receiver: faker.string.uuid(), + debitAmount: { + value: 0, + assetCode: 'USD', + assetScale: 1 + }, + receiveAmount: { + value: 0, + assetCode: 'USD', + assetScale: 1 + }, + sentAmount: { + value: 0, + assetCode: 'USD', + assetScale: 1 + }, + stateAttempts: 0, + createdAt: faker.string.uuid(), + updatedAt: faker.string.uuid(), + balance: '0', + metadata: { + description: 'Free Money!' + }, + peerId: faker.string.uuid() +} + export const mockOnWebhookRequest = ( overrides?: Partial ): OnWebHook => { @@ -242,7 +280,22 @@ export const mockOnWebhookRequest = ( body: { id: faker.string.alpha(10), type: EventType.IncomingPaymentCreated, - data: {} + data: { + id: faker.string.alpha(10), + walletAddressId: uuid(), + createdAt: faker.string.uuid(), + expiresAt: faker.string.uuid(), + receivedAmount: { + value: 0, + assetCode: 'USD', + assetScale: 1 + }, + completed: false, + updatedAt: faker.string.uuid(), + metadata: { + description: 'Free Money!' + } + } }, ...overrides } @@ -252,17 +305,56 @@ export const mockRafikiService = { onWebHook: () => {} } -export function mockOutgoingPaymenteCreatedEvent( +export function mockOutgoingPaymentCreatedEvent( wh: Partial -): WebHook { +): WebhookType { return { id: 'mockedId', type: wh.type || EventType.OutgoingPaymentCreated, + data: wh.data || mockOutgoingPaymentData + } +} + +export function mockOutgoingPaymentCompletedEvent( + wh: Partial +): WebhookType { + return { + id: 'mockedId', + type: wh.type || EventType.OutgoingPaymentCompleted, + data: wh.data || mockOutgoingPaymentData + } +} + +export function mockOutgoingPaymentFailedEvent( + wh: Partial +): WebhookType { + return { + id: 'mockedId', + type: wh.type || EventType.OutgoingPaymentFailed, + data: wh.data || mockOutgoingPaymentData + } +} + +export function mockIncomingPaymentCreatedEvent( + wh: Partial +): WebhookType { + return { + id: 'mockedId', + type: wh.type || EventType.IncomingPaymentCreated, data: wh.data || { - debitAmount: { - value: 0.0, + id: 'mockedId', + walletAddressId: faker.string.uuid(), + createdAt: faker.string.uuid(), + expiresAt: faker.string.uuid(), + receivedAmount: { + value: 0, assetCode: 'USD', assetScale: 1 + }, + completed: false, + updatedAt: faker.string.uuid(), + metadata: { + description: 'Free Money!' } } } diff --git a/packages/wallet/backend/tests/rafiki/service.test.ts b/packages/wallet/backend/tests/rafiki/service.test.ts index 492f2eacf..43bd57347 100644 --- a/packages/wallet/backend/tests/rafiki/service.test.ts +++ b/packages/wallet/backend/tests/rafiki/service.test.ts @@ -1,11 +1,13 @@ import { env } from '@/config/env' import { Cradle, createContainer } from '@/createContainer' -import { RafikiService } from '@/rafiki/service' +import { EventType, RafikiService } from '@/rafiki/service' import { Knex } from 'knex' import { createApp, TestApp } from '../app' import { mockedListAssets, - mockOutgoingPaymenteCreatedEvent, + mockOutgoingPaymentCompletedEvent, + mockOutgoingPaymentCreatedEvent, + mockOutgoingPaymentFailedEvent, mockWalletAddress } from '../mocks' import { truncateTables } from '@shared/backend/tests' @@ -110,21 +112,82 @@ describe('Rafiki Service', () => { createMockRafikiServiceDeps(walletAddress) - const webHook = mockOutgoingPaymenteCreatedEvent({}) + const webHook = mockOutgoingPaymentCreatedEvent({}) const result = await rafikiService.onWebHook(webHook) expect(result).toBeUndefined() }) + it('call outgoing payment should fail because invalid input', async () => { + const { walletAddress } = await prepareRafikiDependencies() + + createMockRafikiServiceDeps(walletAddress) + + const webHook = mockOutgoingPaymentCreatedEvent({ + data: { debitAmount: {} } + }) + + await expect(rafikiService.onWebHook(webHook)).rejects.toThrowError( + /Invalid Input for outgoing_payment.created/ + ) + }) + it('call outgoing payment should fail because because invalid input', async () => { + const { walletAddress } = await prepareRafikiDependencies() + + createMockRafikiServiceDeps(walletAddress) + + const webHook = mockOutgoingPaymentCreatedEvent({ + data: { debitAmount: { value: '' } } + }) + + await expect(rafikiService.onWebHook(webHook)).rejects.toThrowError( + /Invalid Input for outgoing_payment.created/ + ) + }) + + it('should call outgoing payment completed successfully', async () => { + const { walletAddress } = await prepareRafikiDependencies() + + createMockRafikiServiceDeps(walletAddress) + + const webHook = mockOutgoingPaymentCompletedEvent({}) + + const result = await rafikiService.onWebHook(webHook) + expect(result).toBeUndefined() + }) + + it('call outgoing payment completed should fail because invalid input', async () => { + const webHook = mockOutgoingPaymentCompletedEvent({ data: {} }) + + await expect(rafikiService.onWebHook(webHook)).rejects.toThrowError( + /Invalid Input for outgoing_payment.completed/ + ) + }) + + it('should call outgoing payment failed successfully', async () => { + const webHook = mockOutgoingPaymentFailedEvent({}) + + const result = await rafikiService.onWebHook(webHook) + expect(result).toBeUndefined() + }) + it('call outgoing payment failed should fail because invalid data', async () => { + const webHook = mockOutgoingPaymentFailedEvent({ + data: { debitAmount: {} } + }) + + //const result = await rafikiService.onWebHook(webHook) + await expect(rafikiService.onWebHook(webHook)).rejects.toThrowError( + /Invalid Input for outgoing_payment.failed/ + ) + }) - // TODO - Fix the typescript checking error to create te test case for unknow event type - /* it('should throw an error unknow event type mock-event', async () => { - // eslint-disable-next-line no-use-before-define - const webHook = mockOutgoingPaymenteCreatedEvent({type: "mock-event"}) + it('should throw an error unknow event type mock-event', async () => { + const webHook = mockOutgoingPaymentCreatedEvent({ + type: 'mock-event' as EventType + }) await expect(rafikiService.onWebHook(webHook)).rejects.toThrowError( - /unknow event type mock-event/ + /unknown event type, mock-event/ ) }) - */ }) })