From bef2be503f3c7fcf026738ee5a1767e6d6343051 Mon Sep 17 00:00:00 2001 From: sanducb <167857039+sanducb@users.noreply.github.com> Date: Tue, 1 Oct 2024 10:15:51 +0300 Subject: [PATCH] feat(wallet/backend): add lock and unlock endpoints for cards (#1656) * Add lock and unlock endpoints * Address comments * Update routes * Update request methods --- packages/wallet/backend/src/app.ts | 2 + .../wallet/backend/src/card/controller.ts | 46 ++- packages/wallet/backend/src/card/service.ts | 20 +- packages/wallet/backend/src/card/types.ts | 8 + .../wallet/backend/src/card/validation.ts | 28 ++ packages/wallet/backend/src/gatehub/client.ts | 40 ++- .../backend/tests/cards/controller.test.ts | 302 ++++++++++++------ packages/wallet/shared/src/types/card.ts | 7 + packages/wallet/shared/src/types/index.ts | 1 + 9 files changed, 360 insertions(+), 94 deletions(-) create mode 100644 packages/wallet/shared/src/types/card.ts diff --git a/packages/wallet/backend/src/app.ts b/packages/wallet/backend/src/app.ts index 8aaf62825..c31f5c785 100644 --- a/packages/wallet/backend/src/app.ts +++ b/packages/wallet/backend/src/app.ts @@ -313,6 +313,8 @@ export class App { cardController.getCardsByCustomer ) router.get('/cards/:cardId/details', isAuth, cardController.getCardDetails) + router.put('/cards/:cardId/lock', isAuth, cardController.lock) + router.put('/cards/:cardId/unlock', isAuth, cardController.unlock) // Return an error for invalid routes router.use('*', (req: Request, res: CustomResponse) => { diff --git a/packages/wallet/backend/src/card/controller.ts b/packages/wallet/backend/src/card/controller.ts index 581c3f469..e961961c5 100644 --- a/packages/wallet/backend/src/card/controller.ts +++ b/packages/wallet/backend/src/card/controller.ts @@ -5,14 +5,23 @@ import { toSuccessResponse } from '@shared/backend' import { ICardDetailsRequest, ICardDetailsResponse, - ICardResponse + ICardLockRequest, + ICardResponse, + ICardUnlockRequest } from './types' import { validate } from '@/shared/validate' -import { getCardsByCustomerSchema, getCardDetailsSchema } from './validation' +import { + getCardsByCustomerSchema, + getCardDetailsSchema, + lockCardSchema, + unlockCardSchema +} from './validation' export interface ICardController { getCardsByCustomer: Controller getCardDetails: Controller + lock: Controller + unlock: Controller } export class CardController implements ICardController { @@ -55,4 +64,37 @@ export class CardController implements ICardController { next(error) } } + + public lock = async (req: Request, res: Response, next: NextFunction) => { + try { + const { params, query, body } = await validate(lockCardSchema, req) + const { cardId } = params + const { reasonCode } = query + const requestBody: ICardLockRequest = body + + const result = await this.cardService.lock( + cardId, + reasonCode, + requestBody + ) + + res.status(200).json(toSuccessResponse(result)) + } catch (error) { + next(error) + } + } + + public unlock = async (req: Request, res: Response, next: NextFunction) => { + try { + const { params, body } = await validate(unlockCardSchema, req) + const { cardId } = params + const requestBody: ICardUnlockRequest = body + + const result = await this.cardService.unlock(cardId, requestBody) + + res.status(200).json(toSuccessResponse(result)) + } catch (error) { + next(error) + } + } } diff --git a/packages/wallet/backend/src/card/service.ts b/packages/wallet/backend/src/card/service.ts index bd2a023b2..f5da45cf2 100644 --- a/packages/wallet/backend/src/card/service.ts +++ b/packages/wallet/backend/src/card/service.ts @@ -3,8 +3,11 @@ import { GateHubClient } from '../gatehub/client' import { ICardDetailsRequest, ICardDetailsResponse, - ICardResponse + ICardLockRequest, + ICardResponse, + ICardUnlockRequest } from './types' +import { LockReasonCode } from '@wallet/shared/src' import { NotFound } from '@shared/backend' export class CardService { @@ -33,4 +36,19 @@ export class CardService { return this.gateHubClient.getCardDetails(requestBody) } + + async lock( + cardId: string, + reasonCode: LockReasonCode, + requestBody: ICardLockRequest + ): Promise { + return this.gateHubClient.lockCard(cardId, reasonCode, requestBody) + } + + async unlock( + cardId: string, + requestBody: ICardUnlockRequest + ): Promise { + return this.gateHubClient.unlockCard(cardId, requestBody) + } } diff --git a/packages/wallet/backend/src/card/types.ts b/packages/wallet/backend/src/card/types.ts index f3802e544..1d8c96b2c 100644 --- a/packages/wallet/backend/src/card/types.ts +++ b/packages/wallet/backend/src/card/types.ts @@ -94,3 +94,11 @@ export interface ICardProductResponse { name: string cost: string } + +export interface ICardLockRequest { + note: string +} + +export interface ICardUnlockRequest { + note: string +} diff --git a/packages/wallet/backend/src/card/validation.ts b/packages/wallet/backend/src/card/validation.ts index d43f84b2d..f9d5df710 100644 --- a/packages/wallet/backend/src/card/validation.ts +++ b/packages/wallet/backend/src/card/validation.ts @@ -14,3 +14,31 @@ export const getCardDetailsSchema = z.object({ publicKeyBase64: z.string() }) }) + +export const lockCardSchema = z.object({ + params: z.object({ + cardId: z.string() + }), + query: z.object({ + reasonCode: z.enum([ + 'ClientRequestedLock', + 'LostCard', + 'StolenCard', + 'IssuerRequestGeneral', + 'IssuerRequestFraud', + 'IssuerRequestLegal' + ]) + }), + body: z.object({ + note: z.string() + }) +}) + +export const unlockCardSchema = z.object({ + params: z.object({ + cardId: z.string() + }), + body: z.object({ + note: z.string() + }) +}) diff --git a/packages/wallet/backend/src/gatehub/client.ts b/packages/wallet/backend/src/gatehub/client.ts index 469eb6f28..1eac5953b 100644 --- a/packages/wallet/backend/src/gatehub/client.ts +++ b/packages/wallet/backend/src/gatehub/client.ts @@ -30,7 +30,7 @@ import { } from '@/gatehub/consts' import axios, { AxiosError } from 'axios' import { Logger } from 'winston' -import { IFRAME_TYPE } from '@wallet/shared/src' +import { IFRAME_TYPE, LockReasonCode } from '@wallet/shared/src' import { BadRequest } from '@shared/backend' import { ICardDetailsResponse, @@ -39,7 +39,9 @@ import { ICreateCustomerRequest, ICreateCustomerResponse, ICardProductResponse, - ICardDetailsRequest + ICardDetailsRequest, + ICardLockRequest, + ICardUnlockRequest } from '@/card/types' export class GateHubClient { @@ -371,6 +373,40 @@ export class GateHubClient { return cardDetailsResponse } + async lockCard( + cardId: string, + reasonCode: LockReasonCode, + requestBody: ICardLockRequest + ): Promise { + let url = `${this.apiUrl}/v1/cards/${cardId}/lock` + url += `?reasonCode=${encodeURIComponent(reasonCode)}` + + return this.request( + 'PUT', + url, + JSON.stringify(requestBody), + { + cardAppId: this.env.GATEHUB_CARD_APP_ID + } + ) + } + + async unlockCard( + cardId: string, + requestBody: ICardUnlockRequest + ): Promise { + const url = `${this.apiUrl}/v1/cards/${cardId}/unlock` + + return this.request( + 'PUT', + url, + JSON.stringify(requestBody), + { + cardAppId: this.env.GATEHUB_CARD_APP_ID + } + ) + } + private async request( method: HTTP_METHODS, url: string, diff --git a/packages/wallet/backend/tests/cards/controller.test.ts b/packages/wallet/backend/tests/cards/controller.test.ts index 3b888e2b9..04694de65 100644 --- a/packages/wallet/backend/tests/cards/controller.test.ts +++ b/packages/wallet/backend/tests/cards/controller.test.ts @@ -34,7 +34,9 @@ describe('CardController', () => { const mockCardService = { getCardsByCustomer: jest.fn(), - getCardDetails: jest.fn() + getCardDetails: jest.fn(), + lock: jest.fn(), + unlock: jest.fn() } const args = mockLogInRequest().body @@ -85,127 +87,249 @@ describe('CardController', () => { await knex.destroy() }) - it('should get cards by customer successfully', async () => { - const next = jest.fn() - - const mockedCards: ICardResponse[] = [ - { - sourceId: '3dc96e41-279d-4355-921a-e1946e90e1ff', - nameOnCard: 'Jane Doe', - id: 'test-card-id', - accountId: '469E3666F8914020B6B2604F7D4A10F6', - accountSourceId: 'c44e6bc8-d0ef-491e-b374-6d09b6fa6332', - maskedPan: '528700******9830', - status: 'Active', - statusReasonCode: null, - lockLevel: null, - expiryDate: '0929', - customerId: 'customer-id', - customerSourceId: 'a5aba6c7-b8ad-4cfe-98d5-497366a4ee2c', - productCode: 'VMDTKPREB' - } - ] + describe('getCardsByCustomer', () => { + it('should get cards by customer successfully', async () => { + const next = jest.fn() + + const mockedCards: ICardResponse[] = [ + { + sourceId: '3dc96e41-279d-4355-921a-e1946e90e1ff', + nameOnCard: 'Jane Doe', + id: 'test-card-id', + accountId: '469E3666F8914020B6B2604F7D4A10F6', + accountSourceId: 'c44e6bc8-d0ef-491e-b374-6d09b6fa6332', + maskedPan: '528700******9830', + status: 'Active', + statusReasonCode: null, + lockLevel: null, + expiryDate: '0929', + customerId: 'customer-id', + customerSourceId: 'a5aba6c7-b8ad-4cfe-98d5-497366a4ee2c', + productCode: 'VMDTKPREB' + } + ] + + mockCardService.getCardsByCustomer.mockResolvedValue(mockedCards) + + req.params.customerId = 'customer-id' + + await cardController.getCardsByCustomer(req, res, next) + + expect(mockCardService.getCardsByCustomer).toHaveBeenCalledWith( + 'customer-id' + ) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toEqual({ + success: true, + message: 'SUCCESS', + result: mockedCards + }) + }) - mockCardService.getCardsByCustomer.mockResolvedValue(mockedCards) + it('should return 400 if customerId is missing', async () => { + const next = jest.fn() - req.params.customerId = 'customer-id' + delete req.params.customerId - await cardController.getCardsByCustomer(req, res, next) + await cardController.getCardsByCustomer(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) - expect(mockCardService.getCardsByCustomer).toHaveBeenCalledWith( - 'customer-id' - ) - expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ - success: true, - message: 'SUCCESS', - result: mockedCards + expect(next).toHaveBeenCalled() + const error = next.mock.calls[0][0] + expect(error).toBeInstanceOf(BadRequest) + expect(error.message).toBe('Invalid input') + expect(res.statusCode).toBe(400) }) }) - it('should return 400 if customerId is missing', async () => { - const next = jest.fn() + describe('getCardDetails', () => { + it('should get card details successfully', async () => { + const next = jest.fn() - delete req.params.customerId + req.body = { publicKeyBase64: 'test-public-key' } - await cardController.getCardsByCustomer(req, res, (err) => { - next(err) - res.status(err.statusCode).json({ - success: false, - message: err.message + const mockedCardDetails: ICardDetailsResponse = { + cipher: 'encrypted-card-data' + } + + mockCardService.getCardDetails.mockResolvedValue(mockedCardDetails) + + await cardController.getCardDetails(req, res, next) + + expect(mockCardService.getCardDetails).toHaveBeenCalledWith(userId, { + cardId: 'test-card-id', + publicKeyBase64: 'test-public-key' + }) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toEqual({ + success: true, + message: 'SUCCESS', + result: mockedCardDetails }) }) - expect(next).toHaveBeenCalled() - const error = next.mock.calls[0][0] - expect(error).toBeInstanceOf(BadRequest) - expect(error.message).toBe('Invalid input') - expect(res.statusCode).toBe(400) - }) + it('should return 400 if cardId is missing', async () => { + const next = jest.fn() + + delete req.params.cardId - it('should get card details successfully', async () => { - const next = jest.fn() + await cardController.getCardsByCustomer(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) - req.body = { publicKeyBase64: 'test-public-key' } + expect(next).toHaveBeenCalled() + const error = next.mock.calls[0][0] + expect(error).toBeInstanceOf(BadRequest) + expect(error.message).toBe('Invalid input') + expect(res.statusCode).toBe(400) + }) - const mockedCardDetails: ICardDetailsResponse = { - cipher: 'encrypted-card-data' - } + it('should return 400 if publicKeyBase64 is missing', async () => { + const next = jest.fn() - mockCardService.getCardDetails.mockResolvedValue(mockedCardDetails) + req.params.cardId = 'test-card-id' + req.body = {} - await cardController.getCardDetails(req, res, next) + await cardController.getCardDetails(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) - expect(mockCardService.getCardDetails).toHaveBeenCalledWith(userId, { - cardId: 'test-card-id', - publicKeyBase64: 'test-public-key' - }) - expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ - success: true, - message: 'SUCCESS', - result: mockedCardDetails + expect(next).toHaveBeenCalled() + const error = next.mock.calls[0][0] + expect(error).toBeInstanceOf(BadRequest) + expect(error.message).toBe('Invalid input') + expect(res.statusCode).toBe(400) }) }) - it('should return 400 if cardId is missing', async () => { - const next = jest.fn() + describe('lock', () => { + it('should return 400 if reasonCode is missing', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.body = { note: 'Lost my card' } + delete req.query.reasonCode - delete req.params.cardId + await cardController.lock(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) - await cardController.getCardsByCustomer(req, res, (err) => { - next(err) - res.status(err.statusCode).json({ + expect(next).toHaveBeenCalledWith(expect.any(BadRequest)) + expect(res.statusCode).toBe(400) + expect(res._getJSONData()).toMatchObject({ success: false, - message: err.message + message: 'Invalid input' }) }) - expect(next).toHaveBeenCalled() - const error = next.mock.calls[0][0] - expect(error).toBeInstanceOf(BadRequest) - expect(error.message).toBe('Invalid input') - expect(res.statusCode).toBe(400) - }) + it('should return 400 if reasonCode is invalid', async () => { + const next = jest.fn() - it('should return 400 if publicKeyBase64 is missing', async () => { - const next = jest.fn() + req.params.cardId = 'test-card-id' + req.query.reasonCode = 'InvalidCode' + req.body = { note: 'Lost my card' } - req.params.cardId = 'test-card-id' - req.body = {} + await cardController.lock(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) - await cardController.getCardDetails(req, res, (err) => { - next(err) - res.status(err.statusCode).json({ + expect(next).toHaveBeenCalledWith(expect.any(BadRequest)) + expect(res.statusCode).toBe(400) + expect(res._getJSONData()).toMatchObject({ success: false, - message: err.message + message: 'Invalid input' }) }) - expect(next).toHaveBeenCalled() - const error = next.mock.calls[0][0] - expect(error).toBeInstanceOf(BadRequest) - expect(error.message).toBe('Invalid input') - expect(res.statusCode).toBe(400) + it('should return 400 if note is missing', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.query.reasonCode = 'LostCard' + req.body = {} + + await cardController.lock(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) + + expect(next).toHaveBeenCalledWith(expect.any(BadRequest)) + expect(res.statusCode).toBe(400) + expect(res._getJSONData()).toMatchObject({ + success: false, + message: 'Invalid input' + }) + }) + }) + + describe('unlock', () => { + it('should unlock the card successfully', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.body = { note: 'Found my card' } + + const mockResult = { status: 'unlocked' } + mockCardService.unlock.mockResolvedValue(mockResult) + + await cardController.unlock(req, res, next) + + expect(mockCardService.unlock).toHaveBeenCalledWith('test-card-id', { + note: 'Found my card' + }) + + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toEqual({ + success: true, + message: 'SUCCESS', + result: mockResult + }) + }) + + it('should return 400 if note is missing', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.body = {} + + await cardController.unlock(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) + + expect(next).toHaveBeenCalledWith(expect.any(BadRequest)) + expect(res.statusCode).toBe(400) + expect(res._getJSONData()).toMatchObject({ + success: false, + message: 'Invalid input' + }) + }) }) }) diff --git a/packages/wallet/shared/src/types/card.ts b/packages/wallet/shared/src/types/card.ts new file mode 100644 index 000000000..db620f146 --- /dev/null +++ b/packages/wallet/shared/src/types/card.ts @@ -0,0 +1,7 @@ +export type LockReasonCode = + | 'ClientRequestedLock' + | 'LostCard' + | 'StolenCard' + | 'IssuerRequestGeneral' + | 'IssuerRequestFraud' + | 'IssuerRequestLegal' diff --git a/packages/wallet/shared/src/types/index.ts b/packages/wallet/shared/src/types/index.ts index d8baf80ee..ad25b2eb9 100644 --- a/packages/wallet/shared/src/types/index.ts +++ b/packages/wallet/shared/src/types/index.ts @@ -8,3 +8,4 @@ export * from './grant' export * from './walletAddress' export * from './user' export * from './iframe' +export * from './card'