diff --git a/.gitignore b/.gitignore index 41c06f5..1993f48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Environment .env +.nx # compiled output dist diff --git a/apps/cdd-backend/src/app-redis/app-redis.service.spec.ts b/apps/cdd-backend/src/app-redis/app-redis.service.spec.ts index 05094f7..bcf69ed 100644 --- a/apps/cdd-backend/src/app-redis/app-redis.service.spec.ts +++ b/apps/cdd-backend/src/app-redis/app-redis.service.spec.ts @@ -72,7 +72,6 @@ describe('AppRedisService', () => { const exampleCode = { code: 'someCode' } as unknown as NetkiAccessLinkModel; it('should add codes', async () => { - redis.scard.mockResolvedValue(1); redis.sadd.mockResolvedValue(1); const result = await service.pushNetkiCodes([exampleCode]); @@ -80,7 +79,17 @@ describe('AppRedisService', () => { expect(redis.sadd).toHaveBeenCalledWith(netkiAvailableCodesPrefix, [ JSON.stringify(exampleCode), ]); - expect(result).toEqual({ added: 1, total: 1 }); + expect(result).toEqual(1); + }); + }); + + describe('getAccessCodeCount', () => { + it('should return code count', async () => { + redis.scard.mockResolvedValue(3); + + const result = await service.getAccessCodeCount(); + + expect(result).toEqual(3); }); }); diff --git a/apps/cdd-backend/src/app-redis/app-redis.service.ts b/apps/cdd-backend/src/app-redis/app-redis.service.ts index 3c5dcaf..2cf54e5 100644 --- a/apps/cdd-backend/src/app-redis/app-redis.service.ts +++ b/apps/cdd-backend/src/app-redis/app-redis.service.ts @@ -11,6 +11,7 @@ import { netkiAddressPrefixer, netkiBusinessAppPrefixer, netkiBusinessAppPrefix, + netkiBusinessToAddressPrefixer, } from './utils'; @Injectable() @@ -81,23 +82,22 @@ export class AppRedisService { await this.redis.del(netkiAccessCodeKey); } - async pushNetkiCodes( - newCodes: NetkiAccessLinkModel[] - ): Promise<{ added: number; total: number }> { + async pushNetkiCodes(newCodes: NetkiAccessLinkModel[]): Promise { const added = await this.redis.sadd( netkiAvailableCodesPrefix, newCodes.map((link) => JSON.stringify(link)) ); - const total = await this.redis.scard(netkiAvailableCodesPrefix); - this.logger.info('added new netki codes', { attemptedToAdd: newCodes.length, added, - total, }); - return { added, total }; + return added; + } + + async getAccessCodeCount(): Promise { + return this.redis.scard(netkiAvailableCodesPrefix); } async popNetkiAccessLink(): Promise { @@ -117,8 +117,6 @@ export class AppRedisService { this.redis.keys(`${netkiBusinessAppPrefix}*`), ]); - console.log({ allocatedIndividualCodes, allocatedBusinessCodes }); - return new Set( [...allocatedIndividualCodes, ...allocatedBusinessCodes].map((code) => code @@ -128,6 +126,26 @@ export class AppRedisService { ); } + async setBusinessIdToAddress( + businessId: string, + address: string + ): Promise { + const prefixedKey = netkiBusinessToAddressPrefixer(businessId); + + this.logger.debug('associating netki business ID to address', { + businessId, + address, + }); + + await this.redis.set(prefixedKey, address); + } + + async getNetkiBusinessAddress(businessId: string): Promise { + const businessKey = netkiBusinessToAddressPrefixer(businessId); + + return this.redis.get(businessKey); + } + async availableNetkiCodeCount(): Promise { return await this.redis.scard(netkiAvailableCodesPrefix); } diff --git a/apps/cdd-backend/src/app-redis/utils.ts b/apps/cdd-backend/src/app-redis/utils.ts index 4b6ca2e..b72660b 100644 --- a/apps/cdd-backend/src/app-redis/utils.ts +++ b/apps/cdd-backend/src/app-redis/utils.ts @@ -1,9 +1,13 @@ export const netkiAvailableCodesPrefix = 'netki-codes' as const; export const netkiAllocatedCodePrefix = 'netki-allocated-codes:' as const; export const netkiBusinessAppPrefix = 'netki-business-codes:' as const; +export const netkiBusinessToAddressPrefix = 'netki-business-address:' as const; export const netkiAddressPrefixer = (id: string) => `${netkiAllocatedCodePrefix}${id}`; export const netkiBusinessAppPrefixer = (id: string) => `${netkiBusinessAppPrefix}${id}`; + +export const netkiBusinessToAddressPrefixer = (address: string) => + `${netkiBusinessToAddressPrefix}${address}`; diff --git a/apps/cdd-backend/src/cdd-worker/cdd.processor.spec.ts b/apps/cdd-backend/src/cdd-worker/cdd.processor.spec.ts index 6c52361..032a690 100644 --- a/apps/cdd-backend/src/cdd-worker/cdd.processor.spec.ts +++ b/apps/cdd-backend/src/cdd-worker/cdd.processor.spec.ts @@ -4,7 +4,12 @@ import { Polymesh } from '@polymeshassociation/polymesh-sdk'; import { Job } from 'bull'; import { MockPolymesh } from '../test-utils/mocks'; import { CddProcessor } from './cdd.processor'; -import { JumioCddJob, MockCddJob, NetkiCddJob } from './types'; +import { + JumioCddJob, + MockCddJob, + NetkiBusinessJob, + NetkiCddJob, +} from './types'; import { JumioCallbackDto } from '../jumio/types'; import jumioVerifiedData from '../test-utils/jumio-http/webhook-approved-verified.json'; @@ -193,6 +198,85 @@ describe('cddProcessor', () => { processor.generateCdd(mockNetkiCompletedJob) ).rejects.toThrowError(); }); + + it('should assign the address to business ID in case of a business application', async () => { + mockNetkiCompletedJob.data.value.identity.transaction_identity.identity_access_code.business = + 'netkiBusinessId'; + + mockRedis.getNetkiAddress.mockResolvedValue(null); + mockRedis.getNetkiBusinessApplication.mockResolvedValue({ + id: 'someId', + accessCode: 'someCode', + link: 'someLink', + address: 'someAddress', + timestamp: new Date().toISOString(), + }); + + await processor.generateCdd(mockNetkiCompletedJob); + + expect(mockRedis.setBusinessIdToAddress).toHaveBeenCalledWith( + 'netkiBusinessId', + 'someAddress' + ); + }); + + it('should throw an error if there is no business in the callback', async () => { + mockRedis.getNetkiAddress.mockResolvedValue(null); + mockRedis.getNetkiBusinessApplication.mockResolvedValue({ + id: 'someId', + accessCode: 'someCode', + link: 'someLink', + address: 'someAddress', + timestamp: new Date().toISOString(), + }); + + await expect( + processor.generateCdd(mockNetkiCompletedJob) + ).rejects.toThrow(); + }); + }); + + describe('netki business job', () => { + let mockNetkiCompletedJob: Job; + + beforeEach(() => { + mockNetkiCompletedJob = { + ...createMock(), + data: { + type: 'netki-kyb', + value: { + parent_business: 'someBusinessId', + status: 'accepted', + }, + }, + }; + }); + + it('should create a cdd claim', async () => { + mockRedis.getNetkiBusinessAddress.mockResolvedValue('someAddress'); + + await processor.generateCdd(mockNetkiCompletedJob); + + expect(mockPolymesh.identities.registerIdentity).toHaveBeenCalledWith( + { + targetAccount: 'someAddress', + createCdd: true, + }, + { signingAccount: 'netkiSignerAddress' } + ); + }); + + it('should not create a cdd claim if status is not accepted', async () => { + mockRedis.getNetkiBusinessAddress.mockResolvedValue('someAddress'); + + mockNetkiCompletedJob.data.value.status = 'rejected'; + + await processor.generateCdd(mockNetkiCompletedJob); + + expect( + mockPolymesh.identities.registerIdentity + ).not.toHaveBeenCalled(); + }); }); describe('restarted job', () => { diff --git a/apps/cdd-backend/src/cdd-worker/cdd.processor.ts b/apps/cdd-backend/src/cdd-worker/cdd.processor.ts index 6a8a384..8da3906 100644 --- a/apps/cdd-backend/src/cdd-worker/cdd.processor.ts +++ b/apps/cdd-backend/src/cdd-worker/cdd.processor.ts @@ -13,6 +13,7 @@ import { JobIdentifier, JumioCddJob, NetkiCddJob, + NetkiBusinessJob, } from './types'; import { Identity } from '@polymeshassociation/polymesh-sdk/types'; import { NetkiBusinessApplicationModel } from '../app-redis/models/netki-business-application.model'; @@ -36,6 +37,8 @@ export class CddProcessor { await this.handleNetki(job.data); } else if (job.data.type === 'mock') { await this.handleMockJob(job.data); + } else if (job.data.type === 'netki-kyb') { + await this.handleNetkiBusiness(job.data); } else { throw new Error('unknown CDD job type encountered'); } @@ -43,10 +46,44 @@ export class CddProcessor { this.logger.info('finished processing CDD job', { jobId: job.id }); } + private async handleNetkiBusiness({ + value: { parent_business: businessId, status }, + }: NetkiBusinessJob): Promise { + if (status !== 'accepted') { + this.logger.info( + 'netki business callback did not have accepted status. No action will be taken' + ); + + return; + } + + const address = await this.redis.getNetkiBusinessAddress(businessId); + + if (!address) { + this.logger.info( + 'there was no address associated to the business ID - no action will be taken', + { + businessId, + } + ); + + return; + } + + await this.createCddClaim( + { id: businessId, provider: 'netki' }, + address, + 'netki' + ); + } + private async handleNetki({ value: netki }: NetkiCddJob): Promise { const { identity: { - transaction_identity: { client_guid: guid }, + transaction_identity: { + client_guid: guid, + identity_access_code: { business }, + }, state, }, } = netki; @@ -83,10 +120,24 @@ export class CddProcessor { await this.createCddClaim(jobId, address, 'netki'); await this.clearAddressApplications(jobId, address); } else if (businessApplication?.address) { - await this.createCddClaim(jobId, businessApplication.address, 'netki'); // should this get its own key? + if (!business) { + this.logger.error( + 'no business ID was in callback for business application', + { jobId, address: businessApplication.address } + ); + + throw new Error( + 'No business ID was in callback, but it was expected' + ); + } + + await this.redis.setBusinessIdToAddress( + business, + businessApplication.address + ); } else { this.logger.info( - 'no address was associated to netki application, no CDD claim is being made' + 'no address was associated to netki business application, no business ID association will be made' ); } } else { diff --git a/apps/cdd-backend/src/cdd-worker/types.ts b/apps/cdd-backend/src/cdd-worker/types.ts index 2b6e343..8b10948 100644 --- a/apps/cdd-backend/src/cdd-worker/types.ts +++ b/apps/cdd-backend/src/cdd-worker/types.ts @@ -1,9 +1,9 @@ import { CddProvider } from '@cdd-onboarding/cdd-types'; import { JumioCallbackDto } from '../jumio/types'; import { MockCddDto } from '../mock-cdd/types'; -import { NetkiCallbackDto } from '../netki/types'; +import { NetkiBusinessCallbackDto, NetkiCallbackDto } from '../netki/types'; -export type CddJob = JumioCddJob | NetkiCddJob | MockCddJob; +export type CddJob = JumioCddJob | NetkiCddJob | NetkiBusinessJob | MockCddJob; export interface JumioCddJob { value: JumioCallbackDto; @@ -15,6 +15,11 @@ export interface NetkiCddJob { type: 'netki'; } +export interface NetkiBusinessJob { + value: NetkiBusinessCallbackDto; + type: 'netki-kyb'; +} + export interface MockCddJob { value: MockCddDto; type: 'mock'; diff --git a/apps/cdd-backend/src/cdd/cdd.controller.ts b/apps/cdd-backend/src/cdd/cdd.controller.ts index 70c0e25..fc84e5d 100644 --- a/apps/cdd-backend/src/cdd/cdd.controller.ts +++ b/apps/cdd-backend/src/cdd/cdd.controller.ts @@ -6,8 +6,6 @@ import { EmailDetailsDto, AddressApplicationsResponse, AddressApplicationsParamsDto, - BusinessLinkDto, - BusinessLinkResponse, } from '@cdd-onboarding/cdd-types'; import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { @@ -48,8 +46,6 @@ export class CddController { return new ProviderLinkResponse(link); } - - @Post('/email') async emailAddress(@Body() body: EmailDetailsDto): Promise { return this.cddService.processEmail(body); diff --git a/apps/cdd-backend/src/common/api-key.guard.spec.ts b/apps/cdd-backend/src/common/api-key.guard.spec.ts new file mode 100644 index 0000000..3ed9fc5 --- /dev/null +++ b/apps/cdd-backend/src/common/api-key.guard.spec.ts @@ -0,0 +1,26 @@ +import { mockHttpContext } from '../test-utils/mocks'; +import { ApiKeyGuard } from './api-key.guard'; + +describe('ApiKeyGuard', () => { + let apiKeyGuard: ApiKeyGuard; + const allowedApiKeys = ['someApiKey']; + + beforeEach(() => { + apiKeyGuard = new ApiKeyGuard(allowedApiKeys); + }); + + describe('canActivate', () => { + it('should return true if the client Authorization header is included in the allowed basic auth', () => { + const httpContext = mockHttpContext('::1', 'Bearer someApiKey', {}); + const canActivate = apiKeyGuard.canActivate(httpContext); + expect(canActivate).toBe(true); + }); + + it('should return false if the client Authorization header is not included in the basic auth', () => { + const httpContext = mockHttpContext('::1', 'Bearer BAD-KEY', {}); + + const canActivate = apiKeyGuard.canActivate(httpContext); + expect(canActivate).toBe(false); + }); + }); +}); diff --git a/apps/cdd-backend/src/common/api-key.guard.ts b/apps/cdd-backend/src/common/api-key.guard.ts new file mode 100644 index 0000000..587d5a5 --- /dev/null +++ b/apps/cdd-backend/src/common/api-key.guard.ts @@ -0,0 +1,34 @@ +import { + CanActivate, + ExecutionContext, + Inject, + Injectable, +} from '@nestjs/common'; + +const authHeader = 'Authorization'; + +export const API_KEY_GUARD_CREDENTIALS_PROVIDER = Symbol( + 'API_KEY_GUARD_CREDENTIALS_PROVIDER' +); + +@Injectable() +export class ApiKeyGuard implements CanActivate { + constructor( + @Inject(API_KEY_GUARD_CREDENTIALS_PROVIDER) + private readonly apiKeys: string[] + ) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + + const rawClientAuth = request.header(authHeader); + + if (!rawClientAuth) { + return false; + } + + const apiKey = rawClientAuth.replace('Bearer ', ''); + + return this.apiKeys.includes(apiKey); + } +} diff --git a/apps/cdd-backend/src/config/internal.ts b/apps/cdd-backend/src/config/internal.ts index 4b394c0..2c768d6 100644 --- a/apps/cdd-backend/src/config/internal.ts +++ b/apps/cdd-backend/src/config/internal.ts @@ -13,3 +13,11 @@ export const allowedBasicAuthZ = z 'A comma separated list of `user:password` combinations that can POST webhooks' ) .default(['someUser:somePassword']); + +export const allowedApiKeysZ = z + .string() + .array() + .describe( + 'A comma separated list of api keys that authorize KYB applications' + ) + .default([]); diff --git a/apps/cdd-backend/src/config/server.ts b/apps/cdd-backend/src/config/server.ts index d23a153..2f4a565 100644 --- a/apps/cdd-backend/src/config/server.ts +++ b/apps/cdd-backend/src/config/server.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { allowedBasicAuthZ, allowedIpsZ } from './internal'; +import { allowedApiKeysZ, allowedBasicAuthZ, allowedIpsZ } from './internal'; const configZ = z .object({ @@ -73,6 +73,7 @@ const configZ = z 'http URL users will be directed to when onboarding with Netki' ), allowedBasicAuth: allowedBasicAuthZ, + allowedApiKeys: allowedApiKeysZ, }) .describe('Netki related config'), @@ -129,6 +130,9 @@ export const serverEnvConfig = (): ServerConfig => { allowedBasicAuth: process.env.NETKI_ALLOWED_BASIC_AUTH?.split(',').map( (credential) => credential.trim() ), + allowedApiKeys: process.env.NETKI_ALLOWED_API_KEYS?.split(',').map( + (credential) => credential.trim() + ), }, fractalUrl: process.env.FRACTAL_URL, hCaptcha: { diff --git a/apps/cdd-backend/src/netki/netki.controller.spec.ts b/apps/cdd-backend/src/netki/netki.controller.spec.ts index 9335279..8bad298 100644 --- a/apps/cdd-backend/src/netki/netki.controller.spec.ts +++ b/apps/cdd-backend/src/netki/netki.controller.spec.ts @@ -2,10 +2,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger } from 'winston'; +import { API_KEY_GUARD_CREDENTIALS_PROVIDER } from '../common/api-key.guard'; import { BASIC_AUTH_CREDENTIALS_PROVIDER } from '../common/basic-auth.guard'; import { NetkiController } from './netki.controller'; import { NetkiService } from './netki.service'; -import { NetkiCallbackDto } from './types'; +import { NetkiBusinessCallbackDto, NetkiCallbackDto } from './types'; describe('NetkiController', () => { let controller: NetkiController; @@ -23,6 +24,10 @@ describe('NetkiController', () => { provide: BASIC_AUTH_CREDENTIALS_PROVIDER, useValue: [], }, + { + provide: API_KEY_GUARD_CREDENTIALS_PROVIDER, + useValue: [], + }, { provide: WINSTON_MODULE_PROVIDER, useValue: createMock(), @@ -61,4 +66,17 @@ describe('NetkiController', () => { expect(mockService.queueCddJob).toHaveBeenCalledWith(fakeData); }); }); + + describe('business callback', () => { + it('should call the service', async () => { + const fakeData = 'test-data'; + mockService.queueBusinessJob.mockResolvedValue(undefined); + + await controller.businessCallback( + fakeData as unknown as NetkiBusinessCallbackDto + ); + + expect(mockService.queueBusinessJob).toHaveBeenCalledWith(fakeData); + }); + }); }); diff --git a/apps/cdd-backend/src/netki/netki.controller.ts b/apps/cdd-backend/src/netki/netki.controller.ts index 734785d..16e5773 100644 --- a/apps/cdd-backend/src/netki/netki.controller.ts +++ b/apps/cdd-backend/src/netki/netki.controller.ts @@ -5,9 +5,14 @@ import { import { HttpStatus, UseGuards } from '@nestjs/common'; import { Body, Controller, Post } from '@nestjs/common'; import { ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiKeyGuard } from '../common/api-key.guard'; import { BasicAuthGuard } from '../common/basic-auth.guard'; import { NetkiService } from './netki.service'; -import { NetkiCallbackDto, NetkiFetchCodesResponse } from './types'; +import { + NetkiBusinessCallbackDto, + NetkiCallbackDto, + NetkiFetchCodesResponse, +} from './types'; @Controller('netki') @ApiTags('netki') @@ -35,7 +40,20 @@ export class NetkiController { await this.service.queueCddJob(data); } + @Post('/business/callback') + @ApiBody({ + type: NetkiCallbackDto, + }) + @UseGuards(BasicAuthGuard) + @ApiResponse({ + status: HttpStatus.CREATED, + }) + public async businessCallback(@Body() data: NetkiBusinessCallbackDto) { + await this.service.queueBusinessJob(data); + } + @Post('/business-link') + @UseGuards(ApiKeyGuard) async businessLink( @Body() body: BusinessLinkDto ): Promise { diff --git a/apps/cdd-backend/src/netki/netki.module.ts b/apps/cdd-backend/src/netki/netki.module.ts index 425437c..79230f9 100644 --- a/apps/cdd-backend/src/netki/netki.module.ts +++ b/apps/cdd-backend/src/netki/netki.module.ts @@ -7,6 +7,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { AppBullModule } from '../app-bull/app-bull.module'; import { BullModule } from '@nestjs/bull'; import { BASIC_AUTH_CREDENTIALS_PROVIDER } from '../common/basic-auth.guard'; +import { API_KEY_GUARD_CREDENTIALS_PROVIDER } from '../common/api-key.guard'; @Module({ imports: [ @@ -24,6 +25,12 @@ import { BASIC_AUTH_CREDENTIALS_PROVIDER } from '../common/basic-auth.guard'; config.getOrThrow('netki.allowedBasicAuth'), inject: [ConfigService], }, + { + provide: API_KEY_GUARD_CREDENTIALS_PROVIDER, + useFactory: (config: ConfigService) => + config.getOrThrow('netki.allowedApiKeys'), + inject: [ConfigService], + }, ], controllers: [NetkiController], exports: [NetkiService], diff --git a/apps/cdd-backend/src/netki/netki.service.spec.ts b/apps/cdd-backend/src/netki/netki.service.spec.ts index 5b98e12..1a7e174 100644 --- a/apps/cdd-backend/src/netki/netki.service.spec.ts +++ b/apps/cdd-backend/src/netki/netki.service.spec.ts @@ -94,6 +94,13 @@ describe('NetkiService', () => { }); describe('fetchAccessCodes', () => { + const mockAccessResponse = { + data: { + access: + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY3ODg5MzI1NiwianRpIjoiZDVjYzU0YjViNzgyNDA0YmJhYzM2ZDVkMDdmYzU1ZDIiLCJ1c2VyX2lkIjoiYWJkZWU2ZTQtM2I3Ny00YjI0LWEwMjUtOGVkNDAzNzM5NzAzIn0.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + } as AxiosResponse; + it('should return the amount fetched and total amount of codes', async () => { const mockResponse = { data: { @@ -114,20 +121,13 @@ describe('NetkiService', () => { }, } as AxiosResponse; - const mockAccessResponse = { - data: { - access: - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY3ODg5MzI1NiwianRpIjoiZDVjYzU0YjViNzgyNDA0YmJhYzM2ZDVkMDdmYzU1ZDIiLCJ1c2VyX2lkIjoiYWJkZWU2ZTQtM2I3Ny00YjI0LWEwMjUtOGVkNDAzNzM5NzAzIn0.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - }, - } as AxiosResponse; - jest.spyOn(mockHttp, 'get').mockImplementation(() => of(mockResponse)); jest .spyOn(mockHttp, 'post') .mockImplementation(() => of(mockAccessResponse)); mockRedis.getAllocatedNetkiCodes.mockResolvedValue(new Set(['def'])); - mockRedis.pushNetkiCodes.mockResolvedValue({ added: 1, total: 3 }); + mockRedis.pushNetkiCodes.mockResolvedValue(1); const result = await service.fetchAccessCodes(); @@ -136,6 +136,50 @@ describe('NetkiService', () => { expect.objectContaining({ code: 'abc' }), ]); }); + + it('should loop through all of the pages', async () => { + const mockPageOne = { + data: { + next: 'http://example.com?offset=1', + results: [ + { + id: '123', + code: 'abc', + created: new Date().toISOString(), + parent_code: null, + }, + ], + }, + } as AxiosResponse; + + const mockPageTwo = { + data: { + next: null, + results: [ + { + id: '345', + code: 'def', + created: new Date().toISOString(), + parent_code: null, + }, + ], + }, + } as AxiosResponse; + + jest + .spyOn(mockHttp, 'post') + .mockImplementation(() => of(mockAccessResponse)); + jest.spyOn(mockHttp, 'get').mockImplementationOnce(() => of(mockPageOne)); + jest.spyOn(mockHttp, 'get').mockImplementationOnce(() => of(mockPageTwo)); + + mockRedis.getAllocatedNetkiCodes.mockResolvedValue(new Set()); + mockRedis.pushNetkiCodes.mockResolvedValue(1); + + const result = await service.fetchAccessCodes(); + + expect(result).toEqual({ added: 2, total: 3 }); + expect(mockRedis.pushNetkiCodes).toHaveBeenCalledTimes(2); + }); }); describe('queueCddJob', () => { diff --git a/apps/cdd-backend/src/netki/netki.service.ts b/apps/cdd-backend/src/netki/netki.service.ts index 5fdb0cf..91d622d 100644 --- a/apps/cdd-backend/src/netki/netki.service.ts +++ b/apps/cdd-backend/src/netki/netki.service.ts @@ -21,6 +21,7 @@ import { NetkiFetchCodesResponse, NetkiBusinessInfoPageResponse, NetkiBusinessInfo, + NetkiBusinessCallbackDto, } from './types'; import crypto from 'node:crypto'; import { bullJobOptions } from '../config/consts'; @@ -115,41 +116,56 @@ export class NetkiService { } public async fetchAccessCodes(): Promise { - await this.fetchAccessToken(); - - const { businessId, headers } = this; - - const url = this.pathToUrl( + const { businessId } = this; + let url: string = this.pathToUrl( `business/businesses/${businessId}/access-codes/?is_active=true` ); - const codeResponse = await firstValueFrom( - this.http - .get(url, { headers }) - .pipe(catchError((error) => this.logError(error))) - ); + const allocatedCodes = await this.redis.getAllocatedNetkiCodes(); + + let added = 0; + while (url !== null) { + await this.fetchAccessToken(); + + const { headers } = this; + + const codeResponse = await firstValueFrom( + this.http + .get(url, { headers }) + .pipe(catchError((error) => this.logError(error))) + ); - if (!codeResponse?.data.results) { - this.logError(new Error('no results were present in fetch response')); - throw new InternalServerErrorException(); + if (!codeResponse?.data.results) { + this.logError(new Error('no results were present in fetch response')); + throw new InternalServerErrorException(); + } + + const newLinks = codeResponse?.data?.results + .filter( + // filter any code that has been allocated, the presence of `parent code` implies Netki has allocated the code, for something like a user restart + ({ code, parent_code }) => + !allocatedCodes.has(code) && parent_code === null + ) + .map(({ id, code, created, is_active: isActive }: NetkiAccessCode) => ({ + id, + code, + created, + isActive, + })); + + const codesAdded = await this.redis.pushNetkiCodes(newLinks); + added += codesAdded; + + if (codeResponse.data.next) { + url = codeResponse.data.next; + } else { + break; + } } - const allocatedCodes = await this.redis.getAllocatedNetkiCodes(); + const total = await this.redis.getAccessCodeCount(); - const newLinks = codeResponse?.data?.results - .filter( - // filter any code that has been allocated, the presence of `parent code` implies Netki has allocated the code - ({ code, parent_code }) => - !allocatedCodes.has(code) && parent_code === null - ) - .map(({ id, code, created, is_active: isActive }: NetkiAccessCode) => ({ - id, - code, - created, - isActive, - })); - - return this.redis.pushNetkiCodes(newLinks); + return { added, total }; } private get headers(): Record { @@ -161,7 +177,7 @@ export class NetkiService { }; } - private async fetchAccessToken() { + private async fetchAccessToken(): Promise { if (this.accessToken) { // don't refresh is access isn't expired const expiry = getExpiryFromJwt(this.accessToken); @@ -204,6 +220,17 @@ export class NetkiService { await this.queue.add(job, bullJobOptions); } + public async queueBusinessJob( + jobInfo: NetkiBusinessCallbackDto + ): Promise { + const job: CddJob = { + type: 'netki-kyb', + value: jobInfo, + }; + + await this.queue.add(job, bullJobOptions); + } + private async logError(error: Error) { this.logger.error(error.message, error.stack); } diff --git a/apps/cdd-backend/src/netki/types.ts b/apps/cdd-backend/src/netki/types.ts index 2cede2b..cab3ae0 100644 --- a/apps/cdd-backend/src/netki/types.ts +++ b/apps/cdd-backend/src/netki/types.ts @@ -18,6 +18,7 @@ const NetkiCallbackZ = extendApi( identity_access_code: z.object({ code: z.string(), child_codes: z.array(NetkiAccessCodeZ), + business: z.optional(z.string()), }), }), state: z.string(), @@ -27,6 +28,17 @@ const NetkiCallbackZ = extendApi( export class NetkiCallbackDto extends createZodDto(NetkiCallbackZ) {} +const NetkiBusinessCallbackZ = extendApi( + z.object({ + parent_business: z.string(), + status: z.string(), + }) +); + +export class NetkiBusinessCallbackDto extends createZodDto( + NetkiBusinessCallbackZ +) {} + export interface NetkiAccessCode { id: string; code: string; @@ -58,8 +70,8 @@ export type NetkiAccessCodePageResponse = type NetkiPaginatedResponse = { count: number; - next: null; - previous: null; + next: string | null; + previous: string | null; results: T[]; };