diff --git a/packages/wallet/backend/src/app.ts b/packages/wallet/backend/src/app.ts index 6d9e75908..f9a0de91e 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) router.get('/cards/:cardId/pin', isAuth, cardController.getPin) router.post('/cards/:cardId/change-pin', isAuth, cardController.changePin) diff --git a/packages/wallet/backend/src/card/controller.ts b/packages/wallet/backend/src/card/controller.ts index 41574979f..24378d1cf 100644 --- a/packages/wallet/backend/src/card/controller.ts +++ b/packages/wallet/backend/src/card/controller.ts @@ -5,18 +5,24 @@ import { toSuccessResponse } from '@shared/backend' import { ICardDetailsRequest, ICardDetailsResponse, - ICardResponse + ICardLockRequest, + ICardResponse, + ICardUnlockRequest } from './types' import { validate } from '@/shared/validate' import { getCardsByCustomerSchema, getCardDetailsSchema, + lockCardSchema, + unlockCardSchema, changePinSchema } from './validation' export interface ICardController { getCardsByCustomer: Controller getCardDetails: Controller + lock: Controller + unlock: Controller getPin: Controller } @@ -61,6 +67,39 @@ export class CardController implements ICardController { } } + 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) + } + } + public getPin = async (req: Request, res: Response, next: NextFunction) => { try { const userId = req.session.user.id diff --git a/packages/wallet/backend/src/card/service.ts b/packages/wallet/backend/src/card/service.ts index 6a62b9000..546c7bf2a 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 { @@ -59,4 +62,19 @@ export class CardService { throw new NotFound('Card not found or not associated with the user.') } } + + 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 3ba58a0a5..72ef2dcab 100644 --- a/packages/wallet/backend/src/card/validation.ts +++ b/packages/wallet/backend/src/card/validation.ts @@ -15,6 +15,34 @@ export const getCardDetailsSchema = z.object({ }) }) +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() + }) +}) + export const changePinSchema = z.object({ params: z.object({ cardId: z.string() diff --git a/packages/wallet/backend/src/gatehub/client.ts b/packages/wallet/backend/src/gatehub/client.ts index f696f379b..b2c28bc1a 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 + } + ) + } + async getPin( requestBody: ICardDetailsRequest ): Promise { diff --git a/packages/wallet/backend/tests/cards/controller.test.ts b/packages/wallet/backend/tests/cards/controller.test.ts index ec1991cad..e6792ee12 100644 --- a/packages/wallet/backend/tests/cards/controller.test.ts +++ b/packages/wallet/backend/tests/cards/controller.test.ts @@ -35,6 +35,8 @@ describe('CardController', () => { const mockCardService = { getCardsByCustomer: jest.fn(), getCardDetails: jest.fn(), + lock: jest.fn(), + unlock: jest.fn(), getPin: jest.fn(), changePin: jest.fn() } @@ -87,50 +89,98 @@ 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.query = { publicKeyBase64: 'test-public-key' } - await cardController.getCardsByCustomer(req, res, (err) => { + 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 + }) + }) + + it('should return 400 if cardId is missing', async () => { + const next = jest.fn() + + delete req.params.cardId + + await cardController.getCardDetails(req, res, (err) => { next(err) res.status(err.statusCode).json({ success: false, @@ -138,27 +188,167 @@ describe('CardController', () => { }) }) - 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) + 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 publicKeyBase64 is missing', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.query = {} + + await cardController.getCardDetails(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) + + 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 get card details successfully', async () => { + 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 + + 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' + }) + }) + + it('should return 400 if reasonCode is invalid', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.query.reasonCode = 'InvalidCode' + req.body = { note: 'Lost my card' } + + 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' + }) + }) + + 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' + }) + }) + }) + + it('should get pin successfully', async () => { const next = jest.fn() req.query = { publicKeyBase64: 'test-public-key' } const mockedCardDetails: ICardDetailsResponse = { - cipher: 'encrypted-card-data' + cipher: 'encrypted-card-pin' } - mockCardService.getCardDetails.mockResolvedValue(mockedCardDetails) + mockCardService.getPin.mockResolvedValue(mockedCardDetails) - await cardController.getCardDetails(req, res, next) + await cardController.getPin(req, res, next) - expect(mockCardService.getCardDetails).toHaveBeenCalledWith(userId, { + expect(mockCardService.getPin).toHaveBeenCalledWith(userId, { cardId: 'test-card-id', publicKeyBase64: 'test-public-key' }) @@ -175,7 +365,7 @@ describe('CardController', () => { delete req.params.cardId - await cardController.getCardDetails(req, res, (err) => { + await cardController.getPin(req, res, (err) => { next(err) res.status(err.statusCode).json({ success: false, @@ -196,7 +386,51 @@ describe('CardController', () => { req.params.cardId = 'test-card-id' req.query = {} - await cardController.getCardDetails(req, res, (err) => { + await cardController.getPin(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) + + 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 change pin successfully', async () => { + const next = jest.fn() + req.params.cardId = 'test-card-id' + req.body = { + cypher: 'test-cypher' + } + + mockCardService.changePin.mockResolvedValue({}) + + await cardController.changePin(req, res, next) + + expect(mockCardService.changePin).toHaveBeenCalledWith( + userId, + 'test-card-id', + 'test-cypher' + ) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toEqual({ + success: true, + message: 'SUCCESS', + result: {} + }) + }) + + it('should return 400 if cardId is missing', async () => { + const next = jest.fn() + + delete req.params.cardId + + await cardController.changePin(req, res, (err) => { next(err) res.status(err.statusCode).json({ success: false, @@ -211,6 +445,146 @@ describe('CardController', () => { expect(res.statusCode).toBe(400) }) + it('should return 400 if cypher is missing', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.body = {} + + await cardController.changePin(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) + + 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) + }) + }) + + 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 + + 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' + }) + }) + + it('should return 400 if reasonCode is invalid', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.query.reasonCode = 'InvalidCode' + req.body = { note: 'Lost my card' } + + 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' + }) + }) + + 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' + }) + }) + }) + it('should get pin successfully', async () => { const next = jest.fn() 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'