From 66f8c9e24938a01d85a380bbf8c12a7e5df05dc1 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Fri, 27 Dec 2024 08:26:36 -0500 Subject: [PATCH] refactor(core): Encapsulate logic to create new credential into its own method (#12361) --- .../cli/src/__tests__/project.test-data.ts | 19 +++++ .../__tests__/credentials.controller.test.ts | 82 +++++++++++++++++++ .../__tests__/credentials.service.test.ts | 69 ++++++++++++++++ .../__tests__/credentials.test-data.ts | 62 ++++++++++++++ .../src/credentials/credentials.controller.ts | 20 ++--- .../src/credentials/credentials.service.ts | 21 +++++ 6 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/__tests__/project.test-data.ts create mode 100644 packages/cli/src/credentials/__tests__/credentials.controller.test.ts create mode 100644 packages/cli/src/credentials/__tests__/credentials.test-data.ts diff --git a/packages/cli/src/__tests__/project.test-data.ts b/packages/cli/src/__tests__/project.test-data.ts new file mode 100644 index 0000000000..3ffac36fc8 --- /dev/null +++ b/packages/cli/src/__tests__/project.test-data.ts @@ -0,0 +1,19 @@ +import { nanoId, date, firstName, lastName, email } from 'minifaker'; +import 'minifaker/locales/en'; + +import type { Project, ProjectType } from '@/databases/entities/project'; + +type RawProjectData = Pick; + +const projectName = `${firstName()} ${lastName()} <${email}>`; + +export const createRawProjectData = (payload: Partial): Project => { + return { + createdAt: date(), + updatedAt: date(), + id: nanoId.nanoid(), + name: projectName, + type: 'personal' as ProjectType, + ...payload, + } as Project; +}; diff --git a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts new file mode 100644 index 0000000000..13e72e8003 --- /dev/null +++ b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts @@ -0,0 +1,82 @@ +import { mock } from 'jest-mock-extended'; + +import { createRawProjectData } from '@/__tests__/project.test-data'; +import type { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; +import type { EventService } from '@/events/event.service'; +import type { AuthenticatedRequest } from '@/requests'; + +import { createdCredentialsWithScopes, createNewCredentialsPayload } from './credentials.test-data'; +import { CredentialsController } from '../credentials.controller'; +import type { CredentialsService } from '../credentials.service'; + +describe('CredentialsController', () => { + const eventService = mock(); + const credentialsService = mock(); + const sharedCredentialsRepository = mock(); + + const credentialsController = new CredentialsController( + mock(), + credentialsService, + mock(), + mock(), + mock(), + mock(), + mock(), + sharedCredentialsRepository, + mock(), + eventService, + ); + + let req: AuthenticatedRequest; + beforeAll(() => { + req = { user: { id: '123' } } as AuthenticatedRequest; + }); + + describe('createCredentials', () => { + it('it should create new credentials and emit "credentials-created"', async () => { + // Arrange + + const newCredentialsPayload = createNewCredentialsPayload(); + + req.body = newCredentialsPayload; + + const { data, ...payloadWithoutData } = newCredentialsPayload; + + const createdCredentials = createdCredentialsWithScopes(payloadWithoutData); + + const projectOwningCredentialData = createRawProjectData({ + id: newCredentialsPayload.projectId, + }); + + credentialsService.createCredential.mockResolvedValue(createdCredentials); + + sharedCredentialsRepository.findCredentialOwningProject.mockResolvedValue( + projectOwningCredentialData, + ); + + // Act + + const newApiKey = await credentialsController.createCredentials(req); + + // Assert + + expect(credentialsService.createCredential).toHaveBeenCalledWith( + newCredentialsPayload, + req.user, + ); + expect(sharedCredentialsRepository.findCredentialOwningProject).toHaveBeenCalledWith( + createdCredentials.id, + ); + expect(eventService.emit).toHaveBeenCalledWith('credentials-created', { + user: expect.objectContaining({ id: req.user.id }), + credentialId: createdCredentials.id, + credentialType: createdCredentials.type, + projectId: projectOwningCredentialData.id, + projectType: projectOwningCredentialData.type, + publicApi: false, + }); + + expect(newApiKey).toEqual(createdCredentials); + }); + }); +}); diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index 4d1cbd5256..8df0605983 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -1,10 +1,16 @@ import { mock } from 'jest-mock-extended'; +import { nanoId, date } from 'minifaker'; import { CREDENTIAL_EMPTY_VALUE, type ICredentialType } from 'n8n-workflow'; import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; import type { CredentialTypes } from '@/credential-types'; import { CredentialsService } from '@/credentials/credentials.service'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; +import type { AuthenticatedRequest } from '@/requests'; + +import { createNewCredentialsPayload, credentialScopes } from './credentials.test-data'; + +let req = { user: { id: '123' } } as AuthenticatedRequest; describe('CredentialsService', () => { const credType = mock({ @@ -68,4 +74,67 @@ describe('CredentialsService', () => { }); }); }); + + describe('createCredential', () => { + it('it should create new credentials and return with scopes', async () => { + // Arrange + + const encryptedData = 'encryptedData'; + + const newCredentialPayloadData = createNewCredentialsPayload(); + + const newCredential = mock({ + name: newCredentialPayloadData.name, + data: JSON.stringify(newCredentialPayloadData.data), + type: newCredentialPayloadData.type, + }); + + const encryptedDataResponse = { + name: newCredentialPayloadData.name, + type: newCredentialPayloadData.type, + updatedAt: date(), + data: encryptedData, + }; + + const saveCredentialsResponse = { + id: nanoId.nanoid(), + name: newCredentialPayloadData.name, + type: newCredentialPayloadData.type, + updatedAt: encryptedDataResponse.updatedAt, + createdAt: date(), + data: encryptedDataResponse.data, + isManaged: false, + shared: undefined, + }; + + service.prepareCreateData = jest.fn().mockReturnValue(newCredential); + service.createEncryptedData = jest.fn().mockImplementation(() => encryptedDataResponse); + service.save = jest.fn().mockResolvedValue(saveCredentialsResponse); + service.getCredentialScopes = jest.fn().mockReturnValue(credentialScopes); + + // Act + + const createdCredential = await service.createCredential(newCredentialPayloadData, req.user); + + // Assert + + expect(service.prepareCreateData).toHaveBeenCalledWith(newCredentialPayloadData); + expect(service.createEncryptedData).toHaveBeenCalledWith(null, newCredential); + expect(service.save).toHaveBeenCalledWith( + newCredential, + encryptedDataResponse, + req.user, + newCredentialPayloadData.projectId, + ); + expect(service.getCredentialScopes).toHaveBeenCalledWith( + req.user, + saveCredentialsResponse.id, + ); + + expect(createdCredential).toEqual({ + ...saveCredentialsResponse, + scopes: credentialScopes, + }); + }); + }); }); diff --git a/packages/cli/src/credentials/__tests__/credentials.test-data.ts b/packages/cli/src/credentials/__tests__/credentials.test-data.ts new file mode 100644 index 0000000000..8bbbbf3553 --- /dev/null +++ b/packages/cli/src/credentials/__tests__/credentials.test-data.ts @@ -0,0 +1,62 @@ +import type { Scope } from '@n8n/permissions'; +import { nanoId, date } from 'minifaker'; +import { randomString } from 'n8n-workflow'; + +import type { CredentialRequest } from '@/requests'; + +type NewCredentialWithSCopes = { + scopes: Scope[]; + name: string; + data: string; + type: string; + isManaged: boolean; + id: string; + createdAt: Date; + updatedAt: Date; +}; + +const name = 'new Credential'; +const type = 'openAiApi'; +const data = { + apiKey: 'apiKey', + url: 'url', +}; +const projectId = nanoId.nanoid(); + +export const credentialScopes: Scope[] = [ + 'credential:create', + 'credential:delete', + 'credential:list', + 'credential:move', + 'credential:read', + 'credential:share', + 'credential:update', +]; + +export const createNewCredentialsPayload = ( + payload?: Partial, +): CredentialRequest.CredentialProperties => { + return { + name, + type, + data, + projectId, + ...payload, + }; +}; + +export const createdCredentialsWithScopes = ( + payload?: Partial, +): NewCredentialWithSCopes => { + return { + name, + type, + data: randomString(20), + id: nanoId.nanoid(), + createdAt: date(), + updatedAt: date(), + isManaged: false, + scopes: credentialScopes, + ...payload, + }; +}; diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 3868c3b87f..333b7536d0 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -147,32 +147,22 @@ export class CredentialsController { @Post('/') async createCredentials(req: CredentialRequest.Create) { - const newCredential = await this.credentialsService.prepareCreateData(req.body); - - const encryptedData = this.credentialsService.createEncryptedData(null, newCredential); - const { shared, ...credential } = await this.credentialsService.save( - newCredential, - encryptedData, - req.user, - req.body.projectId, - ); + const newCredential = await this.credentialsService.createCredential(req.body, req.user); const project = await this.sharedCredentialsRepository.findCredentialOwningProject( - credential.id, + newCredential.id, ); this.eventService.emit('credentials-created', { user: req.user, - credentialType: credential.type, - credentialId: credential.id, + credentialType: newCredential.type, + credentialId: newCredential.id, publicApi: false, projectId: project?.id, projectType: project?.type, }); - const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id); - - return { ...credential, scopes }; + return newCredential; } @Patch('/:credentialId') diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 3525aecbe9..67d355083a 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -602,4 +602,25 @@ export class CredentialsService { mergedCredentials.data = decryptedData; } } + + /** + * Create a new credential in user's account and return it along the scopes + * If a projectId is send, then it also binds the credential to that specific project + */ + async createCredential(credentialsData: CredentialRequest.CredentialProperties, user: User) { + const newCredential = await this.prepareCreateData(credentialsData); + + const encryptedData = this.createEncryptedData(null, newCredential); + + const { shared, ...credential } = await this.save( + newCredential, + encryptedData, + user, + credentialsData.projectId, + ); + + const scopes = await this.getCredentialScopes(user, credential.id); + + return { ...credential, scopes }; + } }