diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index c19f25623b2a9..0b06bd3d43496 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -75,8 +75,6 @@ import { IDataObject, INodeCredentials, INodeCredentialsDetails, - INodeCredentialTestRequest, - INodeCredentialTestResult, INodeParameters, INodePropertyOptions, INodeType, @@ -114,10 +112,7 @@ import { GenericHelpers, ICredentialsDb, ICredentialsOverwrite, - ICredentialsResponse, ICustomRequest, - IExecutionDeleteFilter, - IExecutionFlatted, IExecutionFlattedDb, IExecutionFlattedResponse, IExecutionPushResponse, @@ -132,7 +127,6 @@ import { ITagWithCountDb, IWorkflowExecutionDataProcess, IWorkflowResponse, - IPersonalizationSurveyAnswers, NodeTypes, Push, ResponseHelper, @@ -171,8 +165,8 @@ import type { import { DEFAULT_EXECUTIONS_GET_ALL_LIMIT, validateEntity } from './GenericHelpers'; import { ExecutionEntity } from './databases/entities/ExecutionEntity'; import { SharedWorkflow } from './databases/entities/SharedWorkflow'; -import { SharedCredentials } from './databases/entities/SharedCredentials'; import { RESPONSE_ERROR_MESSAGES } from './constants'; +import { credentialsEndpoints } from './api/namespaces/credentials'; require('body-parser-xml')(bodyParser); @@ -1556,342 +1550,7 @@ class App { // Credentials // ---------------------------------------- - this.app.get( - `/${this.restEndpoint}/credentials/new`, - ResponseHelper.send(async (req: WorkflowRequest.NewName): Promise<{ name: string }> => { - const requestedName = - req.query.name && req.query.name !== '' ? req.query.name : this.defaultCredentialsName; - - return await GenericHelpers.generateUniqueName(requestedName, 'credentials'); - }), - ); - - // Deletes a specific credential - this.app.delete( - `/${this.restEndpoint}/credentials/:id`, - ResponseHelper.send(async (req: CredentialRequest.Delete) => { - const { id: credentialId } = req.params; - - const shared = await Db.collections.SharedCredentials!.findOne({ - relations: ['credentials'], - where: whereClause({ - user: req.user, - entityType: 'credentials', - entityId: credentialId, - }), - }); - - if (!shared) { - throw new ResponseHelper.ResponseError( - `Credential with ID "${credentialId}" could not be found to be deleted.`, - undefined, - 404, - ); - } - - await this.externalHooks.run('credentials.delete', [credentialId]); - - await Db.collections.Credentials!.delete(credentialId); - - return true; - }), - ); - - // Creates new credentials - this.app.post( - `/${this.restEndpoint}/credentials`, - ResponseHelper.send(async (req: CredentialRequest.Create) => { - delete req.body.id; // delete if sent - - const newCredential = new CredentialsEntity(); - - Object.assign(newCredential, req.body); - - await validateEntity(newCredential); - - // Add the added date for node access permissions - for (const nodeAccess of newCredential.nodesAccess) { - nodeAccess.date = this.getCurrentDate(); - } - - const encryptionKey = await UserSettings.getEncryptionKey(); - - if (!encryptionKey) { - throw new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY); - } - - // Encrypt the data - const coreCredential = new Credentials( - { id: null, name: newCredential.name }, - newCredential.type, - newCredential.nodesAccess, - ); - - // @ts-ignore - coreCredential.setData(newCredential.data, encryptionKey); - - const encryptedData = coreCredential.getDataToSave() as ICredentialsDb; - - Object.assign(newCredential, encryptedData); - - await this.externalHooks.run('credentials.create', [encryptedData]); - - const role = await Db.collections.Role!.findOneOrFail({ - name: 'owner', - scope: 'credential', - }); - - const savedCredential = await getConnection().transaction(async (transactionManager) => { - const savedCredential = await transactionManager.save(newCredential); - - savedCredential.data = newCredential.data; - - const newSharedCredential = new SharedCredentials(); - - Object.assign(newSharedCredential, { - role, - user: req.user, - credentials: savedCredential, - }); - - await transactionManager.save(newSharedCredential); - - return savedCredential; - }); - - const { id, ...rest } = savedCredential; - - return { id: id.toString(), ...rest }; - }), - ); - - // Test credentials - this.app.post( - `/${this.restEndpoint}/credentials-test`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - const incomingData = req.body as INodeCredentialTestRequest; - - const encryptionKey = await UserSettings.getEncryptionKey(); - if (encryptionKey === undefined) { - return { - status: 'Error', - message: 'No encryption key got found to decrypt the credentials!', - }; - } - - const credentialsHelper = new CredentialsHelper(encryptionKey); - - const credentialType = incomingData.credentials.type; - return credentialsHelper.testCredentials( - credentialType, - incomingData.credentials, - incomingData.nodeToTestWith, - ); - }, - ), - ); - - // Updates existing credentials - this.app.patch( - `/${this.restEndpoint}/credentials/:id`, - ResponseHelper.send(async (req: CredentialRequest.Update): Promise => { - const { id: credentialId } = req.params; - - const updateData = new CredentialsEntity(); - Object.assign(updateData, req.body); - - const shared = await Db.collections.SharedCredentials!.findOne({ - relations: ['credentials'], - where: whereClause({ - user: req.user, - entityType: 'credentials', - entityId: credentialId, - }), - }); - - if (!shared) { - throw new ResponseHelper.ResponseError( - `Credential with ID "${credentialId}" could not be found to be updated.`, - undefined, - 404, - ); - } - - const { credentials: credential } = shared; - - // Add the date for newly added node access permissions - for (const nodeAccess of updateData.nodesAccess) { - if (!nodeAccess.date) { - nodeAccess.date = this.getCurrentDate(); - } - } - - const encryptionKey = await UserSettings.getEncryptionKey(); - - if (!encryptionKey) { - throw new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY); - } - - const coreCredential = new Credentials( - { id: credential.id.toString(), name: credential.name }, - credential.type, - credential.nodesAccess, - credential.data, - ); - - const decryptedData = coreCredential.getData(encryptionKey); - - // Do not overwrite the oauth data else data like the access or refresh token would get lost - // everytime anybody changes anything on the credentials even if it is just the name. - if (decryptedData.oauthTokenData) { - // @ts-ignore - updateData.data.oauthTokenData = decryptedData.oauthTokenData; - } - - // Encrypt the data - const credentials = new Credentials( - { id: credentialId, name: updateData.name }, - updateData.type, - updateData.nodesAccess, - ); - - // @ts-ignore - credentials.setData(updateData.data, encryptionKey); - - const newCredentialData = credentials.getDataToSave() as ICredentialsDb; - - // Add special database related data - newCredentialData.updatedAt = this.getCurrentDate(); - - await this.externalHooks.run('credentials.update', [newCredentialData]); - - // Update the credentials in DB - await Db.collections.Credentials!.update(credentialId, newCredentialData); - - // We sadly get nothing back from "update". Neither if it updated a record - // nor the new value. So query now the hopefully updated entry. - const responseData = await Db.collections.Credentials!.findOne(credentialId); - - if (responseData === undefined) { - throw new ResponseHelper.ResponseError( - `Credential with ID "${credentialId}" could not be found to be updated.`, - undefined, - 400, - ); - } - - // Remove the encrypted data as it is not needed in the frontend - const { id, data, ...rest } = responseData; - - return { - id: id.toString(), - ...rest, - }; - }), - ); - - // Returns specific credentials - this.app.get( - `/${this.restEndpoint}/credentials/:id`, - ResponseHelper.send(async (req: CredentialRequest.Get) => { - const { id: credentialId } = req.params; - - const shared = await Db.collections.SharedCredentials!.findOne({ - relations: ['credentials'], - where: whereClause({ - user: req.user, - entityType: 'credentials', - entityId: credentialId, - }), - }); - - if (!shared) return {}; - - const { credentials: credential } = shared; - - if (req.query.includeData !== 'true') { - const { data, id, ...rest } = credential; - - return { - id: id.toString(), - ...rest, - }; - } - - const { data, id, ...rest } = credential; - - const encryptionKey = await UserSettings.getEncryptionKey(); - - if (!encryptionKey) { - throw new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY); - } - - const coreCredential = new Credentials( - { id: credential.id.toString(), name: credential.name }, - credential.type, - credential.nodesAccess, - credential.data, - ); - - return { - id: id.toString(), - data: coreCredential.getData(encryptionKey), - ...rest, - }; - }), - ); - - // Returns all the saved credentials - this.app.get( - `/${this.restEndpoint}/credentials`, - ResponseHelper.send( - async (req: CredentialRequest.GetAll): Promise => { - let credentials: ICredentialsDb[] = []; - - const filter: Record = req.query.filter - ? JSON.parse(req.query.filter) - : {}; - - if (req.user.globalRole.name === 'owner') { - credentials = await Db.collections.Credentials!.find({ - select: ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'], - where: filter, - }); - } else { - const shared = await Db.collections.SharedCredentials!.find({ - relations: ['credentials'], - where: whereClause({ - user: req.user, - entityType: 'credentials', - }), - }); - - if (!shared.length) return []; - - credentials = await Db.collections.Credentials!.find({ - select: ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'], - where: { - id: In(shared.map(({ credentials }) => credentials.id)), - ...filter, - }, - }); - } - - let encryptionKey; - - if (req.query.includeData === 'true') { - encryptionKey = await UserSettings.getEncryptionKey(); - - if (!encryptionKey) { - throw new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY); - } - } - - return credentials.map(({ id, ...rest }) => ({ id: id.toString(), ...rest })); - }, - ), - ); + credentialsEndpoints.apply(this); // ---------------------------------------- // Credential-Types diff --git a/packages/cli/src/UserManagement/Interfaces.ts b/packages/cli/src/UserManagement/Interfaces.ts index 7fff9871f70df..3515eb5bfd323 100644 --- a/packages/cli/src/UserManagement/Interfaces.ts +++ b/packages/cli/src/UserManagement/Interfaces.ts @@ -1,8 +1,8 @@ /* eslint-disable import/no-cycle */ import { Application } from 'express'; import { JwtFromRequestFunction } from 'passport-jwt'; +import type { IExternalHooksClass, IPersonalizationSurveyAnswers } from '../Interfaces'; import { ActiveWorkflowRunner } from '..'; -import { IPersonalizationSurveyAnswers } from '../Interfaces'; export interface JwtToken { token: string; @@ -33,5 +33,7 @@ export interface PublicUser { export interface N8nApp { app: Application; restEndpoint: string; + externalHooks: IExternalHooksClass; + defaultCredentialsName: string; activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner; } diff --git a/packages/cli/src/api/namespaces/credentials.ts b/packages/cli/src/api/namespaces/credentials.ts new file mode 100644 index 0000000000000..7435c08e3269a --- /dev/null +++ b/packages/cli/src/api/namespaces/credentials.ts @@ -0,0 +1,373 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable import/no-cycle */ +import { getConnection, In } from 'typeorm'; +import { UserSettings, Credentials } from 'n8n-core'; +import { INodeCredentialTestResult } from 'n8n-workflow'; + +import { + CredentialsHelper, + Db, + GenericHelpers, + ICredentialsDb, + ICredentialsResponse, + ResponseHelper, + whereClause, +} from '../..'; +import { RESPONSE_ERROR_MESSAGES } from '../../constants'; +import { CredentialsEntity } from '../../databases/entities/CredentialsEntity'; +import { SharedCredentials } from '../../databases/entities/SharedCredentials'; +import { validateEntity } from '../../GenericHelpers'; +import type { CredentialRequest } from '../../requests'; +import type { N8nApp } from '../../UserManagement/Interfaces'; + +export function credentialsEndpoints(this: N8nApp): void { + /** + * Generate a unique credential name. + */ + this.app.get( + `/${this.restEndpoint}/credentials/new`, + ResponseHelper.send(async (req: CredentialRequest.NewName): Promise<{ name: string }> => { + const { name: newName } = req.query; + + return GenericHelpers.generateUniqueName( + newName ?? this.defaultCredentialsName, + 'credentials', + ); + }), + ); + + /** + * Test a credential. + */ + this.app.post( + `/${this.restEndpoint}/credentials-test`, + ResponseHelper.send(async (req: CredentialRequest.Test): Promise => { + const { credentials, nodeToTestWith } = req.body; + + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + return { + status: 'Error', + message: RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + }; + } + + const helper = new CredentialsHelper(encryptionKey); + + return helper.testCredentials(credentials.type, credentials, nodeToTestWith); + }), + ); + + /** + * Create a credential. + */ + this.app.post( + `/${this.restEndpoint}/credentials`, + ResponseHelper.send(async (req: CredentialRequest.Create) => { + delete req.body.id; // delete if sent + + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, req.body); + + await validateEntity(newCredential); + + // Add the added date for node access permissions + for (const nodeAccess of newCredential.nodesAccess) { + nodeAccess.date = new Date(); + } + + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); + } + + // Encrypt the data + const coreCredential = new Credentials( + { id: null, name: newCredential.name }, + newCredential.type, + newCredential.nodesAccess, + ); + + // @ts-ignore + coreCredential.setData(newCredential.data, encryptionKey); + + const encryptedData = coreCredential.getDataToSave() as ICredentialsDb; + + Object.assign(newCredential, encryptedData); + + await this.externalHooks.run('credentials.create', [encryptedData]); + + const role = await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'credential', + }); + + const { id, ...rest } = await getConnection().transaction(async (transactionManager) => { + const savedCredential = await transactionManager.save(newCredential); + + savedCredential.data = newCredential.data; + + const newSharedCredential = new SharedCredentials(); + + Object.assign(newSharedCredential, { + role, + user: req.user, + credentials: savedCredential, + }); + + await transactionManager.save(newSharedCredential); + + return savedCredential; + }); + + return { id: id.toString(), ...rest }; + }), + ); + + /** + * Delete a credential. + */ + this.app.delete( + `/${this.restEndpoint}/credentials/:id`, + ResponseHelper.send(async (req: CredentialRequest.Delete) => { + const { id: credentialId } = req.params; + + const shared = await Db.collections.SharedCredentials!.findOne({ + relations: ['credentials'], + where: whereClause({ + user: req.user, + entityType: 'credentials', + entityId: credentialId, + }), + }); + + if (!shared) { + throw new ResponseHelper.ResponseError( + `Credential with ID "${credentialId}" could not be found to be deleted.`, + undefined, + 404, + ); + } + + await this.externalHooks.run('credentials.delete', [credentialId]); + + await Db.collections.Credentials!.remove(shared.credentials); + + return true; + }), + ); + + /** + * Update a credential. + */ + this.app.patch( + `/${this.restEndpoint}/credentials/:id`, + ResponseHelper.send(async (req: CredentialRequest.Update): Promise => { + const { id: credentialId } = req.params; + + const updateData = new CredentialsEntity(); + Object.assign(updateData, req.body); + + await validateEntity(updateData); + + const shared = await Db.collections.SharedCredentials!.findOne({ + relations: ['credentials'], + where: whereClause({ + user: req.user, + entityType: 'credentials', + entityId: credentialId, + }), + }); + + if (!shared) { + throw new ResponseHelper.ResponseError( + `Credential with ID "${credentialId}" could not be found to be updated.`, + undefined, + 404, + ); + } + + const { credentials: credential } = shared; + + // Add the date for newly added node access permissions + for (const nodeAccess of updateData.nodesAccess) { + if (!nodeAccess.date) { + nodeAccess.date = new Date(); + } + } + + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); + } + + const coreCredential = new Credentials( + { id: credential.id.toString(), name: credential.name }, + credential.type, + credential.nodesAccess, + credential.data, + ); + + const decryptedData = coreCredential.getData(encryptionKey); + + // Do not overwrite the oauth data else data like the access or refresh token would get lost + // everytime anybody changes anything on the credentials even if it is just the name. + if (decryptedData.oauthTokenData) { + // @ts-ignore + updateData.data.oauthTokenData = decryptedData.oauthTokenData; + } + + // Encrypt the data + const credentials = new Credentials( + { id: credentialId, name: updateData.name }, + updateData.type, + updateData.nodesAccess, + ); + + // @ts-ignore + credentials.setData(updateData.data, encryptionKey); + + const newCredentialData = credentials.getDataToSave() as ICredentialsDb; + + // Add special database related data + newCredentialData.updatedAt = new Date(); + + await this.externalHooks.run('credentials.update', [newCredentialData]); + + // Update the credentials in DB + await Db.collections.Credentials!.update(credentialId, newCredentialData); + + // We sadly get nothing back from "update". Neither if it updated a record + // nor the new value. So query now the updated entry. + const responseData = await Db.collections.Credentials!.findOne(credentialId); + + if (responseData === undefined) { + throw new ResponseHelper.ResponseError( + `Credential ID "${credentialId}" could not be found to be updated.`, + undefined, + 404, + ); + } + + // Remove the encrypted data as it is not needed in the frontend + const { id, data, ...rest } = responseData; + + return { + id: id.toString(), + ...rest, + }; + }), + ); + + /** + * Retrieve a credential. + */ + this.app.get( + `/${this.restEndpoint}/credentials/:id`, + ResponseHelper.send(async (req: CredentialRequest.Get) => { + const { id: credentialId } = req.params; + + const shared = await Db.collections.SharedCredentials!.findOne({ + relations: ['credentials'], + where: whereClause({ + user: req.user, + entityType: 'credentials', + entityId: credentialId, + }), + }); + + if (!shared) return {}; + + const { credentials: credential } = shared; + + if (req.query.includeData !== 'true') { + const { data, id, ...rest } = credential; + + return { + id: id.toString(), + ...rest, + }; + } + + const { data, id, ...rest } = credential; + + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + undefined, + 500, + ); + } + + const coreCredential = new Credentials( + { id: credential.id.toString(), name: credential.name }, + credential.type, + credential.nodesAccess, + credential.data, + ); + + return { + id: id.toString(), + data: coreCredential.getData(encryptionKey), + ...rest, + }; + }), + ); + + /** + * Retrieve all credentials. + */ + this.app.get( + `/${this.restEndpoint}/credentials`, + ResponseHelper.send(async (req: CredentialRequest.GetAll): Promise => { + let credentials: ICredentialsDb[] = []; + + const filter = req.query.filter + ? (JSON.parse(req.query.filter) as Record) + : {}; + + if (req.user.globalRole.name === 'owner') { + credentials = await Db.collections.Credentials!.find({ + select: ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'], + where: filter, + }); + } else { + const shared = await Db.collections.SharedCredentials!.find({ + where: whereClause({ + user: req.user, + entityType: 'credentials', + }), + }); + + if (!shared.length) return []; + + credentials = await Db.collections.Credentials!.find({ + select: ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'], + where: { + id: In(shared.map(({ credentialId }) => credentialId)), + ...filter, + }, + }); + } + + return credentials.map(({ id, ...rest }) => ({ id: id.toString(), ...rest })); + }), + ); +} diff --git a/packages/cli/src/databases/entities/CredentialsEntity.ts b/packages/cli/src/databases/entities/CredentialsEntity.ts index a6d2796e0d32c..e6518e6c4b1c7 100644 --- a/packages/cli/src/databases/entities/CredentialsEntity.ts +++ b/packages/cli/src/databases/entities/CredentialsEntity.ts @@ -13,7 +13,7 @@ import { UpdateDateColumn, } from 'typeorm'; -import { Length } from 'class-validator'; +import { IsArray, IsObject, IsString, Length } from 'class-validator'; import config = require('../../../config'); import { DatabaseType, ICredentialsDb } from '../..'; import { SharedCredentials } from './SharedCredentials'; @@ -55,15 +55,18 @@ export class CredentialsEntity implements ICredentialsDb { id: number; @Column({ length: 128 }) + @IsString({ message: 'Credential `name` must be of type string.' }) @Length(3, 128, { message: 'Credential name must be $constraint1 to $constraint2 characters long.', }) name: string; @Column('text') + @IsObject() data: string; @Index() + @IsString({ message: 'Credential `type` must be of type string.' }) @Column({ length: 32 }) type: string; @@ -71,6 +74,7 @@ export class CredentialsEntity implements ICredentialsDb { shared: SharedCredentials[]; @Column(resolveDataType('json')) + @IsArray() nodesAccess: ICredentialNodeAccess[]; @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) diff --git a/packages/cli/src/databases/entities/SharedCredentials.ts b/packages/cli/src/databases/entities/SharedCredentials.ts index fac6e2d8eaa0a..43bbc4e9240cb 100644 --- a/packages/cli/src/databases/entities/SharedCredentials.ts +++ b/packages/cli/src/databases/entities/SharedCredentials.ts @@ -1,5 +1,12 @@ /* eslint-disable import/no-cycle */ -import { BeforeUpdate, CreateDateColumn, Entity, ManyToOne, UpdateDateColumn } from 'typeorm'; +import { + BeforeUpdate, + CreateDateColumn, + Entity, + ManyToOne, + RelationId, + UpdateDateColumn, +} from 'typeorm'; import { IsDate, IsOptional } from 'class-validator'; import config = require('../../../config'); @@ -30,12 +37,18 @@ export class SharedCredentials { @ManyToOne(() => User, (user) => user.sharedCredentials, { primary: true }) user: User; + @RelationId((sharedCredential: SharedCredentials) => sharedCredential.user) + userId: string; + @ManyToOne(() => CredentialsEntity, (credentials) => credentials.shared, { primary: true, onDelete: 'CASCADE', }) credentials: CredentialsEntity; + @RelationId((sharedCredential: SharedCredentials) => sharedCredential.credentials) + credentialId: number; + @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) @IsOptional() // ignored by validation because set at DB level @IsDate() diff --git a/packages/cli/src/databases/entities/SharedWorkflow.ts b/packages/cli/src/databases/entities/SharedWorkflow.ts index 552649728c39c..5e20477e360b6 100644 --- a/packages/cli/src/databases/entities/SharedWorkflow.ts +++ b/packages/cli/src/databases/entities/SharedWorkflow.ts @@ -1,5 +1,12 @@ /* eslint-disable import/no-cycle */ -import { BeforeUpdate, CreateDateColumn, Entity, ManyToOne, UpdateDateColumn } from 'typeorm'; +import { + BeforeUpdate, + CreateDateColumn, + Entity, + ManyToOne, + RelationId, + UpdateDateColumn, +} from 'typeorm'; import { IsDate, IsOptional } from 'class-validator'; import config = require('../../../config'); @@ -30,12 +37,18 @@ export class SharedWorkflow { @ManyToOne(() => User, (user) => user.sharedWorkflows, { primary: true }) user: User; + @RelationId((sharedWorkflow: SharedWorkflow) => sharedWorkflow.user) + userId: string; + @ManyToOne(() => WorkflowEntity, (workflow) => workflow.shared, { primary: true, onDelete: 'CASCADE', }) workflow: WorkflowEntity; + @RelationId((sharedWorkflow: SharedWorkflow) => sharedWorkflow.workflow) + workflowId: number; + @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) @IsOptional() // ignored by validation because set at DB level @IsDate() diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts index 4297b3e6e319c..8480dc7e9e2cd 100644 --- a/packages/cli/src/requests.d.ts +++ b/packages/cli/src/requests.d.ts @@ -1,6 +1,7 @@ /* eslint-disable import/no-cycle */ import express = require('express'); import { + INodeCredentialTestRequest, IConnections, ICredentialDataDecryptedObject, ICredentialNodeAccess, @@ -9,7 +10,7 @@ import { } from 'n8n-workflow'; import { User } from './databases/entities/User'; -import { IExecutionDeleteFilter } from '.'; +import type { IExecutionDeleteFilter } from '.'; import type { PublicUser } from './UserManagement/Interfaces'; export type AuthenticatedRequest< @@ -70,11 +71,13 @@ export declare namespace CredentialRequest { type Delete = Get; - type GetAll = AuthenticatedRequest<{}, {}, {}, { filter: string; includeData: string }>; + type GetAll = AuthenticatedRequest<{}, {}, {}, { filter: string }>; type Update = AuthenticatedRequest<{ id: string }, {}, RequestBody>; type NewName = WorkflowRequest.NewName; + + type Test = AuthenticatedRequest<{}, {}, INodeCredentialTestRequest>; } // ---------------------------------- diff --git a/packages/cli/test/integration/auth.endpoints.test.ts b/packages/cli/test/integration/auth.endpoints.test.ts index f37ba32608a27..852cda1d8d80b 100644 --- a/packages/cli/test/integration/auth.endpoints.test.ts +++ b/packages/cli/test/integration/auth.endpoints.test.ts @@ -54,7 +54,7 @@ describe('auth endpoints', () => { }); test('POST /login should log user in', async () => { - const authlessAgent = await utils.createAgent(app, { auth: false }); + const authlessAgent = await utils.createAgent(app); const response = await authlessAgent.post('/login').send({ email: TEST_USER.email, diff --git a/packages/cli/test/integration/credentials.endpoints.test.ts b/packages/cli/test/integration/credentials.endpoints.test.ts new file mode 100644 index 0000000000000..157e960123ce1 --- /dev/null +++ b/packages/cli/test/integration/credentials.endpoints.test.ts @@ -0,0 +1,541 @@ +import express = require('express'); +import { getConnection } from 'typeorm'; +import { UserSettings } from 'n8n-core'; + +import { Db } from '../../src'; +import { randomName, randomString } from './shared/random'; +import * as utils from './shared/utils'; +import type { SaveCredentialFunction } from './shared/types'; + +let app: express.Application; +let saveCredential: SaveCredentialFunction; + +beforeAll(async () => { + app = utils.initTestServer({ + namespaces: ['credentials'], + applyAuth: true, + externalHooks: true, + }); + await utils.initTestDb(); + utils.initConfigFile(); + + const credentialOwnerRole = await utils.getCredentialOwnerRole(); + saveCredential = utils.affixRoleToSaveCredential(credentialOwnerRole); +}); + +beforeEach(async () => { + await utils.createOwnerShell(); +}); + +afterEach(async () => { + await utils.truncate(['User', 'Credentials', 'SharedCredentials']); +}); + +afterAll(() => { + return getConnection().close(); +}); + +test('POST /credentials should create cred', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + const payload = credentialPayload(); + + const response = await authOwnerAgent.post('/credentials').send(payload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(payload.name); + expect(type).toBe(payload.type); + expect(nodesAccess[0].nodeType).toBe(payload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(payload.data); + + const credential = await Db.collections.Credentials!.findOneOrFail(id); + + expect(credential.name).toBe(payload.name); + expect(credential.type).toBe(payload.type); + expect(credential.nodesAccess[0].nodeType).toBe(payload.nodesAccess[0].nodeType); + expect(credential.data).not.toBe(payload.data); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['user', 'credentials'], + where: { credentials: credential }, + }); + + expect(sharedCredential.user.id).toBe(owner.id); + expect(sharedCredential.credentials.name).toBe(payload.name); +}); + +test('POST /credentials should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + for (const invalidPayload of INVALID_PAYLOADS) { + const response = await authOwnerAgent.post('/credentials').send(invalidPayload); + expect(response.statusCode).toBe(400); + } +}); + +test('POST /credentials should fail with missing encryption key', async () => { + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockResolvedValue(undefined); + + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/credentials').send(credentialPayload()); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); +}); + +test('POST /credentials should ignore ID in payload', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const firstResponse = await authOwnerAgent + .post('/credentials') + .send({ id: '8', ...credentialPayload() }); + + expect(firstResponse.body.data.id).not.toBe('8'); + + const secondResponse = await authOwnerAgent + .post('/credentials') + .send({ id: 8, ...credentialPayload() }); + + expect(secondResponse.body.data.id).not.toBe(8); +}); + +test('DELETE /credentials/:id should delete owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ data: true }); + + const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(deletedCredential).toBeUndefined(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeUndefined(); // deleted +}); + +test('DELETE /credentials/:id should delete non-owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + const member = await utils.createUser(); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + + const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ data: true }); + + const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(deletedCredential).toBeUndefined(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeUndefined(); // deleted +}); + +test('DELETE /credentials/:id should delete owned cred for member', async () => { + const member = await utils.createUser(); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ data: true }); + + const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(deletedCredential).toBeUndefined(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeUndefined(); // deleted +}); + +test('DELETE /credentials/:id should not delete non-owned cred for member', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const member = await utils.createUser(); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(404); + + const shellCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(shellCredential).toBeDefined(); // not deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeDefined(); // not deleted +}); + +test('DELETE /credentials/:id should fail if cred not found', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.delete('/credentials/123'); + + expect(response.statusCode).toBe(404); +}); + +test('PATCH /credentials/:id should update owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + const patchPayload = credentialPayload(); + + const response = await authOwnerAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(patchPayload.name); + expect(type).toBe(patchPayload.type); + expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(patchPayload.data); + + const credential = await Db.collections.Credentials!.findOneOrFail(id); + + expect(credential.name).toBe(patchPayload.name); + expect(credential.type).toBe(patchPayload.type); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(credential.data).not.toBe(patchPayload.data); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['credentials'], + where: { credentials: credential }, + }); + + expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated +}); + +test('PATCH /credentials/:id should update non-owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + const member = await utils.createUser(); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + const patchPayload = credentialPayload(); + + const response = await authOwnerAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(patchPayload.name); + expect(type).toBe(patchPayload.type); + expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(patchPayload.data); + + const credential = await Db.collections.Credentials!.findOneOrFail(id); + + expect(credential.name).toBe(patchPayload.name); + expect(credential.type).toBe(patchPayload.type); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(credential.data).not.toBe(patchPayload.data); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['credentials'], + where: { credentials: credential }, + }); + + expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated +}); + +test('PATCH /credentials/:id should update owned cred for member', async () => { + const member = await utils.createUser(); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + const patchPayload = credentialPayload(); + + const response = await authMemberAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(patchPayload.name); + expect(type).toBe(patchPayload.type); + expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(patchPayload.data); + + const credential = await Db.collections.Credentials!.findOneOrFail(id); + + expect(credential.name).toBe(patchPayload.name); + expect(credential.type).toBe(patchPayload.type); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + expect(credential.data).not.toBe(patchPayload.data); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['credentials'], + where: { credentials: credential }, + }); + + expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated +}); + +test('PATCH /credentials/:id should not update non-owned cred for member', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const member = await utils.createUser(); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + const patchPayload = credentialPayload(); + + const response = await authMemberAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(404); + + const shellCredential = await Db.collections.Credentials!.findOneOrFail(savedCredential.id); + + expect(shellCredential.name).not.toBe(patchPayload.name); // not updated +}); + +test('PATCH /credentials/:id should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + for (const invalidPayload of INVALID_PAYLOADS) { + const response = await authOwnerAgent + .patch(`/credentials/${savedCredential.id}`) + .send(invalidPayload); + + expect(response.statusCode).toBe(400); + } +}); + +test('PATCH /credentials/:id should fail if cred not found', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.patch('/credentials/123').send(credentialPayload()); + + expect(response.statusCode).toBe(404); +}); + +test('PATCH /credentials/:id should fail with missing encryption key', async () => { + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockResolvedValue(undefined); + + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/credentials').send(credentialPayload()); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); +}); + +test('GET /credentials should retrieve all creds for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + for (let i = 0; i < 3; i++) { + await saveCredential(credentialPayload(), { user: owner }); + } + + const member = await utils.createUser(); + + await saveCredential(credentialPayload(), { user: member }); + + const response = await authOwnerAgent.get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(4); // 3 owner + 1 member + + for (const credential of response.body.data) { + const { name, type, nodesAccess, data: encryptedData } = credential; + + expect(typeof name).toBe('string'); + expect(typeof type).toBe('string'); + expect(typeof nodesAccess[0].nodeType).toBe('string'); + expect(encryptedData).toBeUndefined(); + } +}); + +test('GET /credentials should retrieve owned creds for member', async () => { + const member = await utils.createUser(); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); + + for (let i = 0; i < 3; i++) { + await saveCredential(credentialPayload(), { user: member }); + } + + const response = await authMemberAgent.get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + + for (const credential of response.body.data) { + const { name, type, nodesAccess, data: encryptedData } = credential; + + expect(typeof name).toBe('string'); + expect(typeof type).toBe('string'); + expect(typeof nodesAccess[0].nodeType).toBe('string'); + expect(encryptedData).toBeUndefined(); + } +}); + +test('GET /credentials should not retrieve non-owned creds for member', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const member = await utils.createUser(); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); + + for (let i = 0; i < 3; i++) { + await saveCredential(credentialPayload(), { user: owner }); + } + + const response = await authMemberAgent.get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(0); // owner's creds not returned +}); + +test('GET /credentials/:id should retrieve owned cred for owner', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const firstResponse = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); + + expect(firstResponse.statusCode).toBe(200); + + expect(typeof firstResponse.body.data.name).toBe('string'); + expect(typeof firstResponse.body.data.type).toBe('string'); + expect(typeof firstResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + expect(firstResponse.body.data.data).toBeUndefined(); + + const secondResponse = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(secondResponse.statusCode).toBe(200); + expect(typeof secondResponse.body.data.name).toBe('string'); + expect(typeof secondResponse.body.data.type).toBe('string'); + expect(typeof secondResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + expect(secondResponse.body.data.data).toBeDefined(); +}); + +test('GET /credentials/:id should retrieve owned cred for member', async () => { + const member = await utils.createUser(); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: member }); + + const firstResponse = await authMemberAgent.get(`/credentials/${savedCredential.id}`); + + expect(firstResponse.statusCode).toBe(200); + + expect(typeof firstResponse.body.data.name).toBe('string'); + expect(typeof firstResponse.body.data.type).toBe('string'); + expect(typeof firstResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + expect(firstResponse.body.data.data).toBeUndefined(); + + const secondResponse = await authMemberAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(secondResponse.statusCode).toBe(200); + + expect(typeof secondResponse.body.data.name).toBe('string'); + expect(typeof secondResponse.body.data.type).toBe('string'); + expect(typeof secondResponse.body.data.nodesAccess[0].nodeType).toBe('string'); + expect(secondResponse.body.data.data).toBeDefined(); +}); + +test('GET /credentials/:id should not retrieve non-owned cred for member', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const member = await utils.createUser(); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const response = await authMemberAgent.get(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toEqual({}); // owner's cred not returned +}); + +test('GET /credentials/:id should fail with missing encryption key', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + const savedCredential = await saveCredential(credentialPayload(), { user: owner }); + + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockResolvedValue(undefined); + + const response = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); +}); + +test('GET /credentials/:id should return empty if cred not found', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const response = await authMemberAgent.get('/credentials/789'); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ data: {} }); +}); + +const credentialPayload = () => ({ + name: randomName(), + type: randomName(), + nodesAccess: [{ nodeType: randomName() }], + data: { accessToken: randomString(5, 15) }, +}); + +const INVALID_PAYLOADS = [ + { + type: randomName(), + nodesAccess: [{ nodeType: randomName() }], + data: { accessToken: randomString(5, 15) }, + }, + { + name: randomName(), + nodesAccess: [{ nodeType: randomName() }], + data: { accessToken: randomString(5, 15) }, + }, + { + name: randomName(), + type: randomName(), + data: { accessToken: randomString(5, 15) }, + }, + { + name: randomName(), + type: randomName(), + nodesAccess: [{ nodeType: randomName() }], + }, + {}, + [], + undefined, +]; diff --git a/packages/cli/test/integration/shared/constants.ts b/packages/cli/test/integration/shared/constants.ts index 76ee8b402f5cf..413f01f8f72ee 100644 --- a/packages/cli/test/integration/shared/constants.ts +++ b/packages/cli/test/integration/shared/constants.ts @@ -24,14 +24,14 @@ export const TEST_CONNECTION_OPTIONS: Readonly = { logging: false, }; -export const SUCCESS_RESPONSE_BODY: Readonly = { +export const SUCCESS_RESPONSE_BODY = { data: { success: true, }, -}; +} as const; -export const LOGGED_OUT_RESPONSE_BODY: Readonly = { +export const LOGGED_OUT_RESPONSE_BODY = { data: { loggedOut: true, }, -}; +} as const; diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts index be577afde2895..cd88521434d56 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -1,4 +1,9 @@ -import type { N8nApp } from "../../../src/UserManagement/Interfaces"; +import type { ICredentialDataDecryptedObject, ICredentialNodeAccess } from 'n8n-workflow'; +import type { ICredentialsDb } from '../../../src'; +import type { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity'; +import type { User } from '../../../src/databases/entities/User'; + +import type { N8nApp } from '../../../src/UserManagement/Interfaces'; export type SmtpTestAccount = { user: string; @@ -10,6 +15,18 @@ export type SmtpTestAccount = { }; }; -export type EndpointNamespace = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset'; +export type EndpointNamespace = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset' | 'credentials'; export type NamespacesMap = Readonly void>>; + +export type CredentialPayload = { + name: string; + type: string; + nodesAccess: ICredentialNodeAccess[]; + data: ICredentialDataDecryptedObject; +}; + +export type SaveCredentialFunction = ( + credentialPayload: CredentialPayload, + { user }: { user: User }, +) => Promise; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 901b7a77e3321..14fe18669e5bd 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -1,3 +1,5 @@ +import { randomBytes } from 'crypto'; +import { existsSync } from 'fs'; import express = require('express'); import * as superagent from 'superagent'; import * as request from 'supertest'; @@ -7,23 +9,29 @@ import * as util from 'util'; import { createTestAccount } from 'nodemailer'; import { v4 as uuid } from 'uuid'; import { LoggerProxy } from 'n8n-workflow'; +import { Credentials, UserSettings } from 'n8n-core'; +import { getConnection } from 'typeorm'; import config = require('../../../config'); import { AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT } from './constants'; import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes'; -import { Db, IDatabaseCollections } from '../../../src'; -import { User } from '../../../src/databases/entities/User'; +import { Db, ExternalHooks, ICredentialsDb, IDatabaseCollections } from '../../../src'; import { meNamespace as meEndpoints } from '../../../src/UserManagement/routes/me'; import { usersNamespace as usersEndpoints } from '../../../src/UserManagement/routes/users'; import { authenticationMethods as authEndpoints } from '../../../src/UserManagement/routes/auth'; import { ownerNamespace as ownerEndpoints } from '../../../src/UserManagement/routes/owner'; import { passwordResetNamespace as passwordResetEndpoints } from '../../../src/UserManagement/routes/passwordReset'; -import { getConnection } from 'typeorm'; +import { credentialsEndpoints } from '../../../src/api/namespaces/credentials'; import { issueJWT } from '../../../src/UserManagement/auth/jwt'; import { randomEmail, randomValidPassword, randomName } from './random'; -import type { EndpointNamespace, NamespacesMap, SmtpTestAccount } from './types'; -import { Role } from '../../../src/databases/entities/Role'; import { getLogger } from '../../../src/Logger'; +import { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity'; +import { RESPONSE_ERROR_MESSAGES } from '../../../src/constants'; +import type { Role } from '../../../src/databases/entities/Role'; +import type { User } from '../../../src/databases/entities/User'; +import type { CredentialPayload, EndpointNamespace, NamespacesMap, SmtpTestAccount } from './types'; + +export const isTestRun = process.argv[1].split('/').includes('jest'); // TODO: Phase out // ---------------------------------- // test server @@ -43,13 +51,16 @@ export const initLogger = () => { export function initTestServer({ applyAuth, namespaces, + externalHooks, }: { applyAuth: boolean; + externalHooks?: true; namespaces?: EndpointNamespace[]; }) { const testServer = { app: express(), restEndpoint: REST_PATH_SEGMENT, + ...(externalHooks ? { externalHooks: ExternalHooks() } : {}), }; testServer.app.use(bodyParser.json()); @@ -69,6 +80,7 @@ export function initTestServer({ auth: authEndpoints, owner: ownerEndpoints, passwordReset: passwordResetEndpoints, + credentials: credentialsEndpoints, }; for (const namespace of namespaces) { @@ -79,6 +91,30 @@ export function initTestServer({ return testServer.app; } +// ---------------------------------- +// test logger +// ---------------------------------- + +/** + * Initialize a silent logger for test runs. + */ +export function initTestLogger() { + config.set('logs.output', 'file'); + LoggerProxy.init(getLogger()); +}; + +/** + * Initialize a config file if non-existent. + */ +export function initConfigFile() { + const settingsPath = UserSettings.getUserSettingsPath(); + + if (!existsSync(settingsPath)) { + const userSettings = { encryptionKey: randomBytes(24).toString('base64') }; + UserSettings.writeUserSettings(userSettings, settingsPath); + } +} + // ---------------------------------- // test DB // ---------------------------------- @@ -94,6 +130,39 @@ export async function truncate(entities: Array) { await getConnection().query('PRAGMA foreign_keys=ON'); } +export function affixRoleToSaveCredential(role: Role) { + return (credentialPayload: CredentialPayload, { user }: { user: User }) => + saveCredential(credentialPayload, { user, role }); +} + +/** + * Save a credential to the DB, sharing it with a user. + */ +async function saveCredential( + credentialPayload: CredentialPayload, + { user, role }: { user: User; role: Role }, +) { + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, credentialPayload); + + const encryptedData = await encryptCredentialData(newCredential); + + Object.assign(newCredential, encryptedData); + + const savedCredential = await Db.collections.Credentials!.save(newCredential); + + savedCredential.data = newCredential.data; + + await Db.collections.SharedCredentials!.save({ + user, + credentials: savedCredential, + role, + }); + + return savedCredential; +} + /** * Store a user in the DB, defaulting to a `member`. */ @@ -181,19 +250,15 @@ export function getAllRoles() { // request agent // ---------------------------------- -export async function createAgent( - app: express.Application, - { auth, user }: { auth: boolean; user?: User } = { auth: false }, -) { +/** + * Create a request agent, optionally with an auth cookie. + */ +export async function createAgent(app: express.Application, options?: { auth: true; user: User }) { const agent = request.agent(app); agent.use(prefix(REST_PATH_SEGMENT)); - if (auth && !user) { - throw new Error('User required for auth agent creation'); - } - - if (auth && user) { - const { token } = await issueJWT(user); + if (options?.auth && options?.user) { + const { token } = await issueJWT(options.user); agent.jar.setCookie(`n8n-auth=${token}`); } @@ -263,5 +328,25 @@ export async function getHasOwnerSetting() { */ export const getSmtpTestAccount = util.promisify(createTestAccount); -// TODO: Phase out -export const isTestRun = process.argv[1].split('/').includes('jest'); +// ---------------------------------- +// encryption +// ---------------------------------- + +async function encryptCredentialData(credential: CredentialsEntity) { + const encryptionKey = await UserSettings.getEncryptionKey(); + + if (!encryptionKey) { + throw new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY); + } + + const coreCredential = new Credentials( + { id: null, name: credential.name }, + credential.type, + credential.nodesAccess, + ); + + // @ts-ignore + coreCredential.setData(credential.data, encryptionKey); + + return coreCredential.getDataToSave() as ICredentialsDb; +} diff --git a/packages/cli/test/integration/users.endpoints.test.ts b/packages/cli/test/integration/users.endpoints.test.ts index c1c4a78d75b9b..cd67c0883a1a7 100644 --- a/packages/cli/test/integration/users.endpoints.test.ts +++ b/packages/cli/test/integration/users.endpoints.test.ts @@ -7,8 +7,6 @@ import * as utils from './shared/utils'; import { Db } from '../../src'; import config = require('../../config'); import { SUCCESS_RESPONSE_BODY } from './shared/constants'; -import { getLogger } from '../../src/Logger'; -import { LoggerProxy } from 'n8n-workflow'; import { Role } from '../../src/databases/entities/Role'; import { randomEmail, @@ -44,12 +42,11 @@ beforeAll(async () => { workflowOwnerRole = fetchedWorkflowOwnerRole; credentialOwnerRole = fetchedCredentialOwnerRole; - config.set('logs.output', 'file'); // declutter console output - utils.initLogger(); + utils.initTestLogger(); }); beforeEach(async () => { - await utils.truncate(['User']); + await utils.truncate(['User', 'Workflow', 'Credentials', 'SharedCredentials', 'SharedWorkflow']); jest.isolateModules(() => { jest.mock('../../config'); @@ -70,10 +67,6 @@ beforeEach(async () => { UMHelper.isEmailSetUp = false; }); -afterEach(async () => { - await utils.truncate(['User']); -}); - afterAll(() => { return getConnection().close(); }); @@ -157,27 +150,27 @@ test('DELETE /users/:id should delete the user', async () => { expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); const user = await Db.collections.User!.findOne(userToDelete.id); - expect(user).toBeUndefined(); + expect(user).toBeUndefined(); // deleted const sharedWorkflow = await Db.collections.SharedWorkflow!.findOne({ relations: ['user'], where: { user: userToDelete }, }); - expect(sharedWorkflow).toBeUndefined(); + expect(sharedWorkflow).toBeUndefined(); // deleted const sharedCredential = await Db.collections.SharedCredentials!.findOne({ relations: ['user'], where: { user: userToDelete }, }); - expect(sharedCredential).toBeUndefined(); + expect(sharedCredential).toBeUndefined(); // deleted const workflow = await Db.collections.Workflow!.findOne(savedWorkflow.id); - expect(workflow).toBeUndefined(); + expect(workflow).toBeUndefined(); // deleted // TODO: also include active workflow and check whether webhook has been removed const credential = await Db.collections.Credentials!.findOne(savedCredential.id); - expect(credential).toBeUndefined(); + expect(credential).toBeUndefined(); // deleted }); test('DELETE /users/:id should fail to delete self', async () => { @@ -277,8 +270,6 @@ test('DELETE /users/:id with transferId should perform transfer', async () => { expect(sharedWorkflow.user.id).toBe(owner.id); expect(sharedCredential.user.id).toBe(owner.id); expect(deletedUser).toBeUndefined(); - - await utils.truncate(['Credentials', 'Workflow']); }); test('GET /resolve-signup-token should validate invite token', async () => { @@ -338,7 +329,7 @@ test('GET /resolve-signup-token should fail with invalid inputs', async () => { }); test('POST /users/:id should fill out a user shell', async () => { - const authlessAgent = await utils.createAgent(app, { auth: false }); + const authlessAgent = await utils.createAgent(app); const userToFillOut = await Db.collections.User!.save({ email: randomEmail(), @@ -384,7 +375,7 @@ test('POST /users/:id should fill out a user shell', async () => { }); test('POST /users/:id should fail with invalid inputs', async () => { - const authlessAgent = await utils.createAgent(app, { auth: false }); + const authlessAgent = await utils.createAgent(app); const emailToStore = randomEmail(); @@ -405,7 +396,7 @@ test('POST /users/:id should fail with invalid inputs', async () => { }); test('POST /users/:id should fail with already accepted invite', async () => { - const authlessAgent = await utils.createAgent(app, { auth: false }); + const authlessAgent = await utils.createAgent(app); const globalMemberRole = await Db.collections.Role!.findOneOrFail({ name: 'member',