From 51093f649d2d6dbce5e65cfb0486104d5e93a804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 1 Sep 2023 15:13:19 +0200 Subject: [PATCH] refactor: Move community package logic to service (no-changelog) (#6973) --- packages/cli/src/CommunityNodes/helpers.ts | 232 ------------ .../cli/src/CommunityNodes/packageModel.ts | 76 ---- packages/cli/src/LoadNodesAndCredentials.ts | 28 +- packages/cli/src/audit/risks/nodes.risk.ts | 4 +- packages/cli/src/commands/start.ts | 58 +-- .../cli/src/controllers/nodes.controller.ts | 76 ++-- .../installedPackages.repository.ts | 42 ++- .../src/services/communityPackage.service.ts | 306 +++++++++++++++ .../test/integration/audit/nodes.risk.test.ts | 10 +- .../cli/test/integration/nodes.api.test.ts | 253 ++++++------- .../cli/test/integration/shared/testDb.ts | 35 +- packages/cli/test/integration/shared/types.ts | 12 - .../shared/utils/communityNodes.ts | 41 +- .../test/unit/CommunityNodeHelpers.test.ts | 344 ----------------- .../services/communityPackage.service.test.ts | 357 ++++++++++++++++++ 15 files changed, 923 insertions(+), 951 deletions(-) delete mode 100644 packages/cli/src/CommunityNodes/helpers.ts delete mode 100644 packages/cli/src/CommunityNodes/packageModel.ts create mode 100644 packages/cli/src/services/communityPackage.service.ts delete mode 100644 packages/cli/test/unit/CommunityNodeHelpers.test.ts create mode 100644 packages/cli/test/unit/services/communityPackage.service.test.ts diff --git a/packages/cli/src/CommunityNodes/helpers.ts b/packages/cli/src/CommunityNodes/helpers.ts deleted file mode 100644 index 11a3eb9fb1..0000000000 --- a/packages/cli/src/CommunityNodes/helpers.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { promisify } from 'util'; -import { exec } from 'child_process'; -import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises'; -import axios from 'axios'; -import { UserSettings } from 'n8n-core'; -import type { PublicInstalledPackage } from 'n8n-workflow'; -import { LoggerProxy } from 'n8n-workflow'; - -import { - NODE_PACKAGE_PREFIX, - NPM_COMMAND_TOKENS, - NPM_PACKAGE_STATUS_GOOD, - RESPONSE_ERROR_MESSAGES, - UNKNOWN_FAILURE_REASON, -} from '@/constants'; -import type { InstalledPackages } from '@db/entities/InstalledPackages'; -import config from '@/config'; - -import type { CommunityPackages } from '@/Interfaces'; - -const { - PACKAGE_NAME_NOT_PROVIDED, - DISK_IS_FULL, - PACKAGE_FAILED_TO_INSTALL, - PACKAGE_VERSION_NOT_FOUND, - PACKAGE_DOES_NOT_CONTAIN_NODES, - PACKAGE_NOT_FOUND, -} = RESPONSE_ERROR_MESSAGES; - -const { - NPM_PACKAGE_NOT_FOUND_ERROR, - NPM_NO_VERSION_AVAILABLE, - NPM_DISK_NO_SPACE, - NPM_DISK_INSUFFICIENT_SPACE, - NPM_PACKAGE_VERSION_NOT_FOUND_ERROR, -} = NPM_COMMAND_TOKENS; - -const execAsync = promisify(exec); - -const INVALID_OR_SUSPICIOUS_PACKAGE_NAME = /[^0-9a-z@\-./]/; - -export const parseNpmPackageName = (rawString?: string): CommunityPackages.ParsedPackageName => { - if (!rawString) throw new Error(PACKAGE_NAME_NOT_PROVIDED); - - if (INVALID_OR_SUSPICIOUS_PACKAGE_NAME.test(rawString)) - throw new Error('Package name must be a single word'); - - const scope = rawString.includes('/') ? rawString.split('/')[0] : undefined; - - const packageNameWithoutScope = scope ? rawString.replace(`${scope}/`, '') : rawString; - - if (!packageNameWithoutScope.startsWith(NODE_PACKAGE_PREFIX)) { - throw new Error(`Package name must start with ${NODE_PACKAGE_PREFIX}`); - } - - const version = packageNameWithoutScope.includes('@') - ? packageNameWithoutScope.split('@')[1] - : undefined; - - const packageName = version ? rawString.replace(`@${version}`, '') : rawString; - - return { - packageName, - scope, - version, - rawString, - }; -}; - -export const sanitizeNpmPackageName = parseNpmPackageName; - -export const executeCommand = async ( - command: string, - options?: { doNotHandleError?: boolean }, -): Promise => { - const downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath(); - - const execOptions = { - cwd: downloadFolder, - env: { - NODE_PATH: process.env.NODE_PATH, - PATH: process.env.PATH, - APPDATA: process.env.APPDATA, - }, - }; - - try { - await fsAccess(downloadFolder); - } catch { - await fsMkdir(downloadFolder); - // Also init the folder since some versions - // of npm complain if the folder is empty - await execAsync('npm init -y', execOptions); - } - - try { - const commandResult = await execAsync(command, execOptions); - - return commandResult.stdout; - } catch (error) { - if (options?.doNotHandleError) throw error; - - const errorMessage = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON; - - const map = { - [NPM_PACKAGE_NOT_FOUND_ERROR]: PACKAGE_NOT_FOUND, - [NPM_NO_VERSION_AVAILABLE]: PACKAGE_NOT_FOUND, - [NPM_PACKAGE_VERSION_NOT_FOUND_ERROR]: PACKAGE_VERSION_NOT_FOUND, - [NPM_DISK_NO_SPACE]: DISK_IS_FULL, - [NPM_DISK_INSUFFICIENT_SPACE]: DISK_IS_FULL, - }; - - Object.entries(map).forEach(([npmMessage, n8nMessage]) => { - if (errorMessage.includes(npmMessage)) throw new Error(n8nMessage); - }); - - LoggerProxy.warn('npm command failed', { errorMessage }); - - throw new Error(PACKAGE_FAILED_TO_INSTALL); - } -}; - -export function matchPackagesWithUpdates( - packages: InstalledPackages[], - updates?: CommunityPackages.AvailableUpdates, -): PublicInstalledPackage[] { - if (!updates) return packages; - - return packages.reduce((acc, cur) => { - const publicPackage: PublicInstalledPackage = { ...cur }; - - const update = updates[cur.packageName]; - - if (update) publicPackage.updateAvailable = update.latest; - - acc.push(publicPackage); - - return acc; - }, []); -} - -export function matchMissingPackages( - installedPackages: PublicInstalledPackage[], - missingPackages: string, -): PublicInstalledPackage[] { - const missingPackageNames = missingPackages.split(' '); - - const missingPackagesList = missingPackageNames.map((missingPackageName: string) => { - // Strip away versions but maintain scope and package name - try { - const parsedPackageData = parseNpmPackageName(missingPackageName); - return parsedPackageData.packageName; - } catch {} - return undefined; - }); - - const hydratedPackageList = [] as PublicInstalledPackage[]; - - installedPackages.forEach((installedPackage) => { - const hydratedInstalledPackage = { ...installedPackage }; - if (missingPackagesList.includes(hydratedInstalledPackage.packageName)) { - hydratedInstalledPackage.failedLoading = true; - } - hydratedPackageList.push(hydratedInstalledPackage); - }); - - return hydratedPackageList; -} - -export async function checkNpmPackageStatus( - packageName: string, -): Promise { - const N8N_BACKEND_SERVICE_URL = 'https://api.n8n.io/api/package'; - - try { - const response = await axios.post( - N8N_BACKEND_SERVICE_URL, - { name: packageName }, - { method: 'POST' }, - ); - - if (response.data.status !== NPM_PACKAGE_STATUS_GOOD) return response.data; - } catch (error) { - // Do nothing if service is unreachable - } - - return { status: NPM_PACKAGE_STATUS_GOOD }; -} - -export function hasPackageLoaded(packageName: string): boolean { - const missingPackages = config.get('nodes.packagesMissing') as string | undefined; - - if (!missingPackages) return true; - - return !missingPackages - .split(' ') - .some( - (packageNameAndVersion) => - packageNameAndVersion.startsWith(packageName) && - packageNameAndVersion.replace(packageName, '').startsWith('@'), - ); -} - -export function removePackageFromMissingList(packageName: string): void { - try { - const failedPackages = config.get('nodes.packagesMissing').split(' '); - - const packageFailedToLoad = failedPackages.filter( - (packageNameAndVersion) => - !packageNameAndVersion.startsWith(packageName) || - !packageNameAndVersion.replace(packageName, '').startsWith('@'), - ); - - config.set('nodes.packagesMissing', packageFailedToLoad.join(' ')); - } catch { - // Do nothing - } -} - -export const isClientError = (error: Error): boolean => { - const clientErrors = [ - PACKAGE_VERSION_NOT_FOUND, - PACKAGE_DOES_NOT_CONTAIN_NODES, - PACKAGE_NOT_FOUND, - ]; - - return clientErrors.some((message) => error.message.includes(message)); -}; - -export function isNpmError(error: unknown): error is { code: number; stdout: string } { - return typeof error === 'object' && error !== null && 'code' in error && 'stdout' in error; -} diff --git a/packages/cli/src/CommunityNodes/packageModel.ts b/packages/cli/src/CommunityNodes/packageModel.ts deleted file mode 100644 index 46197655c8..0000000000 --- a/packages/cli/src/CommunityNodes/packageModel.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { LoggerProxy } from 'n8n-workflow'; -import type { PackageDirectoryLoader } from 'n8n-core'; -import * as Db from '@/Db'; -import { InstalledNodes } from '@db/entities/InstalledNodes'; -import { InstalledPackages } from '@db/entities/InstalledPackages'; - -export async function findInstalledPackage(packageName: string): Promise { - return Db.collections.InstalledPackages.findOne({ - where: { packageName }, - relations: ['installedNodes'], - }); -} - -export async function isPackageInstalled(packageName: string): Promise { - return Db.collections.InstalledPackages.exist({ - where: { packageName }, - }); -} - -export async function getAllInstalledPackages(): Promise { - return Db.collections.InstalledPackages.find({ relations: ['installedNodes'] }); -} - -export async function removePackageFromDatabase( - packageName: InstalledPackages, -): Promise { - return Db.collections.InstalledPackages.remove(packageName); -} - -export async function persistInstalledPackageData( - packageLoader: PackageDirectoryLoader, -): Promise { - const { packageJson, nodeTypes, loadedNodes } = packageLoader; - const { name: packageName, version: installedVersion, author } = packageJson; - - let installedPackage: InstalledPackages; - - try { - await Db.transaction(async (transactionManager) => { - const promises = []; - - const installedPackagePayload = Object.assign(new InstalledPackages(), { - packageName, - installedVersion, - authorName: author?.name, - authorEmail: author?.email, - }); - installedPackage = await transactionManager.save(installedPackagePayload); - installedPackage.installedNodes = []; - - promises.push( - ...loadedNodes.map(async (loadedNode) => { - const installedNodePayload = Object.assign(new InstalledNodes(), { - name: nodeTypes[loadedNode.name].type.description.displayName, - type: loadedNode.name, - latestVersion: loadedNode.version, - package: packageName, - }); - installedPackage.installedNodes.push(installedNodePayload); - return transactionManager.save(installedNodePayload); - }), - ); - - return promises; - }); - - return installedPackage!; - } catch (error) { - LoggerProxy.error('Failed to save installed packages and nodes', { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - error, - packageName, - }); - throw error; - } -} diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index 269e6ce867..74f717442e 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -23,7 +23,7 @@ import { mkdir } from 'fs/promises'; import path from 'path'; import config from '@/config'; import type { InstalledPackages } from '@db/entities/InstalledPackages'; -import { executeCommand } from '@/CommunityNodes/helpers'; +import { CommunityPackageService } from './services/communityPackage.service'; import { GENERATED_STATIC_DIR, RESPONSE_ERROR_MESSAGES, @@ -34,7 +34,7 @@ import { inE2ETests, } from '@/constants'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; -import { Service } from 'typedi'; +import Container, { Service } from 'typedi'; @Service() export class LoadNodesAndCredentials implements INodesAndCredentials { @@ -189,8 +189,10 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { ? `npm update ${packageName}` : `npm install ${packageName}${options.version ? `@${options.version}` : ''}`; + const communityPackageService = Container.get(CommunityPackageService); + try { - await executeCommand(command); + await communityPackageService.executeNpmCommand(command); } catch (error) { if (error instanceof Error && error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) { throw new Error(`The npm package "${packageName}" could not be found.`); @@ -207,7 +209,7 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { // Remove this package since loading it failed const removeCommand = `npm remove ${packageName}`; try { - await executeCommand(removeCommand); + await communityPackageService.executeNpmCommand(removeCommand); } catch {} throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_LOADING_FAILED, { cause: error }); } @@ -215,11 +217,10 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { if (loader.loadedNodes.length > 0) { // Save info to DB try { - const { persistInstalledPackageData, removePackageFromDatabase } = await import( - '@/CommunityNodes/packageModel' - ); - if (isUpdate) await removePackageFromDatabase(options.installedPackage); - const installedPackage = await persistInstalledPackageData(loader); + if (isUpdate) { + await communityPackageService.removePackageFromDatabase(options.installedPackage); + } + const installedPackage = await communityPackageService.persistInstalledPackage(loader); await this.postProcessLoaders(); await this.generateTypesForFrontend(); return installedPackage; @@ -234,7 +235,7 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { // Remove this package since it contains no loadable nodes const removeCommand = `npm remove ${packageName}`; try { - await executeCommand(removeCommand); + await communityPackageService.executeNpmCommand(removeCommand); } catch {} throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES); @@ -246,12 +247,11 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { } async removeNpmModule(packageName: string, installedPackage: InstalledPackages): Promise { - const command = `npm remove ${packageName}`; + const communityPackageService = Container.get(CommunityPackageService); - await executeCommand(command); + await communityPackageService.executeNpmCommand(`npm remove ${packageName}`); - const { removePackageFromDatabase } = await import('@/CommunityNodes/packageModel'); - await removePackageFromDatabase(installedPackage); + await communityPackageService.removePackageFromDatabase(installedPackage); if (packageName in this.loaders) { this.loaders[packageName].reset(); diff --git a/packages/cli/src/audit/risks/nodes.risk.ts b/packages/cli/src/audit/risks/nodes.risk.ts index 042f34aa8b..4ac7796aa2 100644 --- a/packages/cli/src/audit/risks/nodes.risk.ts +++ b/packages/cli/src/audit/risks/nodes.risk.ts @@ -2,7 +2,7 @@ import * as path from 'path'; import glob from 'fast-glob'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { getNodeTypes } from '@/audit/utils'; -import { getAllInstalledPackages } from '@/CommunityNodes/packageModel'; +import { CommunityPackageService } from '@/services/communityPackage.service'; import { OFFICIAL_RISKY_NODE_TYPES, ENV_VARS_DOCS_URL, @@ -15,7 +15,7 @@ import type { Risk } from '@/audit/types'; import { Container } from 'typedi'; async function getCommunityNodeDetails() { - const installedPackages = await getAllInstalledPackages(); + const installedPackages = await Container.get(CommunityPackageService).getAllInstalledPackages(); return installedPackages.reduce((acc, pkg) => { pkg.installedNodes.forEach((node) => diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index a21a93e21f..8ae5c16fce 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -13,7 +13,7 @@ import replaceStream from 'replacestream'; import { promisify } from 'util'; import glob from 'fast-glob'; -import { LoggerProxy, sleep, jsonParse } from 'n8n-workflow'; +import { sleep, jsonParse } from 'n8n-workflow'; import { createHash } from 'crypto'; import config from '@/config'; @@ -23,7 +23,7 @@ import * as Db from '@/Db'; import * as GenericHelpers from '@/GenericHelpers'; import { Server } from '@/Server'; import { TestWebhooks } from '@/TestWebhooks'; -import { getAllInstalledPackages } from '@/CommunityNodes/packageModel'; +import { CommunityPackageService } from '@/services/communityPackage.service'; import { EDITOR_UI_DIST_DIR, GENERATED_STATIC_DIR } from '@/constants'; import { eventBus } from '@/eventbus'; import { BaseCommand } from './BaseCommand'; @@ -233,54 +233,12 @@ export class Start extends BaseCommand { const areCommunityPackagesEnabled = config.getEnv('nodes.communityPackages.enabled'); if (areCommunityPackagesEnabled) { - const installedPackages = await getAllInstalledPackages(); - const missingPackages = new Set<{ - packageName: string; - version: string; - }>(); - installedPackages.forEach((installedPackage) => { - installedPackage.installedNodes.forEach((installedNode) => { - if (!this.loadNodesAndCredentials.known.nodes[installedNode.type]) { - // Leave the list ready for installing in case we need. - missingPackages.add({ - packageName: installedPackage.packageName, - version: installedPackage.installedVersion, - }); - } - }); - }); - - config.set('nodes.packagesMissing', ''); - if (missingPackages.size) { - LoggerProxy.error( - 'n8n detected that some packages are missing. For more information, visit https://docs.n8n.io/integrations/community-nodes/troubleshooting/', - ); - - if (flags.reinstallMissingPackages || process.env.N8N_REINSTALL_MISSING_PACKAGES) { - LoggerProxy.info('Attempting to reinstall missing packages', { missingPackages }); - try { - // Optimistic approach - stop if any installation fails - - for (const missingPackage of missingPackages) { - await this.loadNodesAndCredentials.installNpmModule( - missingPackage.packageName, - missingPackage.version, - ); - missingPackages.delete(missingPackage); - } - LoggerProxy.info('Packages reinstalled successfully. Resuming regular initialization.'); - } catch (error) { - LoggerProxy.error('n8n was unable to install the missing packages.'); - } - } - - config.set( - 'nodes.packagesMissing', - Array.from(missingPackages) - .map((missingPackage) => `${missingPackage.packageName}@${missingPackage.version}`) - .join(' '), - ); - } + await Container.get(CommunityPackageService).setMissingPackages( + this.loadNodesAndCredentials, + { + reinstallMissingPackages: flags.reinstallMissingPackages, + }, + ); } const dbType = config.getEnv('database.type'); diff --git a/packages/cli/src/controllers/nodes.controller.ts b/packages/cli/src/controllers/nodes.controller.ts index 9d872c8693..993cb081fd 100644 --- a/packages/cli/src/controllers/nodes.controller.ts +++ b/packages/cli/src/controllers/nodes.controller.ts @@ -7,41 +7,45 @@ import { import { Authorized, Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators'; import { NodeRequest } from '@/requests'; import { BadRequestError, InternalServerError } from '@/ResponseHelper'; -import { - checkNpmPackageStatus, - executeCommand, - hasPackageLoaded, - isClientError, - isNpmError, - matchMissingPackages, - matchPackagesWithUpdates, - parseNpmPackageName, - removePackageFromMissingList, - sanitizeNpmPackageName, -} from '@/CommunityNodes/helpers'; -import { - findInstalledPackage, - getAllInstalledPackages, - isPackageInstalled, -} from '@/CommunityNodes/packageModel'; import type { InstalledPackages } from '@db/entities/InstalledPackages'; import type { CommunityPackages } from '@/Interfaces'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { InternalHooks } from '@/InternalHooks'; import { Push } from '@/push'; import { Config } from '@/config'; +import { CommunityPackageService } from '@/services/communityPackage.service'; +import Container from 'typedi'; -const { PACKAGE_NOT_INSTALLED, PACKAGE_NAME_NOT_PROVIDED } = RESPONSE_ERROR_MESSAGES; +const { + PACKAGE_NOT_INSTALLED, + PACKAGE_NAME_NOT_PROVIDED, + PACKAGE_VERSION_NOT_FOUND, + PACKAGE_DOES_NOT_CONTAIN_NODES, + PACKAGE_NOT_FOUND, +} = RESPONSE_ERROR_MESSAGES; + +const isClientError = (error: Error) => + [PACKAGE_VERSION_NOT_FOUND, PACKAGE_DOES_NOT_CONTAIN_NODES, PACKAGE_NOT_FOUND].some((msg) => + error.message.includes(msg), + ); + +export function isNpmError(error: unknown): error is { code: number; stdout: string } { + return typeof error === 'object' && error !== null && 'code' in error && 'stdout' in error; +} @Authorized(['global', 'owner']) @RestController('/nodes') export class NodesController { + private communityPackageService: CommunityPackageService; + constructor( private config: Config, private loadNodesAndCredentials: LoadNodesAndCredentials, private push: Push, private internalHooks: InternalHooks, - ) {} + ) { + this.communityPackageService = Container.get(CommunityPackageService); + } // TODO: move this into a new decorator `@IfConfig('executions.mode', 'queue')` @Middleware() @@ -65,7 +69,7 @@ export class NodesController { let parsed: CommunityPackages.ParsedPackageName; try { - parsed = parseNpmPackageName(name); + parsed = this.communityPackageService.parseNpmPackageName(name); } catch (error) { throw new BadRequestError( error instanceof Error ? error.message : 'Failed to parse package name', @@ -81,8 +85,8 @@ export class NodesController { ); } - const isInstalled = await isPackageInstalled(parsed.packageName); - const hasLoaded = hasPackageLoaded(name); + const isInstalled = await this.communityPackageService.isPackageInstalled(parsed.packageName); + const hasLoaded = this.communityPackageService.hasPackageLoaded(name); if (isInstalled && hasLoaded) { throw new BadRequestError( @@ -93,7 +97,7 @@ export class NodesController { ); } - const packageStatus = await checkNpmPackageStatus(name); + const packageStatus = await this.communityPackageService.checkNpmPackageStatus(name); if (packageStatus.status !== 'OK') { throw new BadRequestError(`Package "${name}" is banned so it cannot be installed`); @@ -126,7 +130,7 @@ export class NodesController { throw new (clientError ? BadRequestError : InternalServerError)(message); } - if (!hasLoaded) removePackageFromMissingList(name); + if (!hasLoaded) this.communityPackageService.removePackageFromMissingList(name); // broadcast to connected frontends that node list has been updated installedPackage.installedNodes.forEach((node) => { @@ -152,7 +156,7 @@ export class NodesController { @Get('/') async getInstalledPackages() { - const installedPackages = await getAllInstalledPackages(); + const installedPackages = await this.communityPackageService.getAllInstalledPackages(); if (installedPackages.length === 0) return []; @@ -160,7 +164,7 @@ export class NodesController { try { const command = ['npm', 'outdated', '--json'].join(' '); - await executeCommand(command, { doNotHandleError: true }); + await this.communityPackageService.executeNpmCommand(command, { doNotHandleError: true }); } catch (error) { // when there are updates, npm exits with code 1 // when there are no updates, command succeeds @@ -170,12 +174,18 @@ export class NodesController { } } - let hydratedPackages = matchPackagesWithUpdates(installedPackages, pendingUpdates); + let hydratedPackages = this.communityPackageService.matchPackagesWithUpdates( + installedPackages, + pendingUpdates, + ); try { const missingPackages = this.config.get('nodes.packagesMissing') as string | undefined; if (missingPackages) { - hydratedPackages = matchMissingPackages(hydratedPackages, missingPackages); + hydratedPackages = this.communityPackageService.matchMissingPackages( + hydratedPackages, + missingPackages, + ); } } catch {} @@ -191,14 +201,14 @@ export class NodesController { } try { - sanitizeNpmPackageName(name); + this.communityPackageService.parseNpmPackageName(name); // sanitize input } catch (error) { const message = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON; throw new BadRequestError(message); } - const installedPackage = await findInstalledPackage(name); + const installedPackage = await this.communityPackageService.findInstalledPackage(name); if (!installedPackage) { throw new BadRequestError(PACKAGE_NOT_INSTALLED); @@ -241,7 +251,9 @@ export class NodesController { throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED); } - const previouslyInstalledPackage = await findInstalledPackage(name); + const previouslyInstalledPackage = await this.communityPackageService.findInstalledPackage( + name, + ); if (!previouslyInstalledPackage) { throw new BadRequestError(PACKAGE_NOT_INSTALLED); @@ -249,7 +261,7 @@ export class NodesController { try { const newInstalledPackage = await this.loadNodesAndCredentials.updateNpmModule( - parseNpmPackageName(name).packageName, + this.communityPackageService.parseNpmPackageName(name).packageName, previouslyInstalledPackage, ); diff --git a/packages/cli/src/databases/repositories/installedPackages.repository.ts b/packages/cli/src/databases/repositories/installedPackages.repository.ts index 7a0a1fff29..06ef523c07 100644 --- a/packages/cli/src/databases/repositories/installedPackages.repository.ts +++ b/packages/cli/src/databases/repositories/installedPackages.repository.ts @@ -1,10 +1,50 @@ import { Service } from 'typedi'; import { DataSource, Repository } from 'typeorm'; import { InstalledPackages } from '../entities/InstalledPackages'; +import { InstalledNodesRepository } from './installedNodes.repository'; +import type { PackageDirectoryLoader } from 'n8n-core'; @Service() export class InstalledPackagesRepository extends Repository { - constructor(dataSource: DataSource) { + constructor( + dataSource: DataSource, + private installedNodesRepository: InstalledNodesRepository, + ) { super(InstalledPackages, dataSource.manager); } + + async saveInstalledPackageWithNodes(packageLoader: PackageDirectoryLoader) { + const { packageJson, nodeTypes, loadedNodes } = packageLoader; + const { name: packageName, version: installedVersion, author } = packageJson; + + let installedPackage: InstalledPackages; + + await this.manager.transaction(async (manager) => { + installedPackage = await manager.save( + this.create({ + packageName, + installedVersion, + authorName: author?.name, + authorEmail: author?.email, + }), + ); + + installedPackage.installedNodes = []; + + return loadedNodes.map(async (loadedNode) => { + const installedNode = this.installedNodesRepository.create({ + name: nodeTypes[loadedNode.name].type.description.displayName, + type: loadedNode.name, + latestVersion: loadedNode.version.toString(), + package: { packageName }, + }); + + installedPackage.installedNodes.push(installedNode); + + return manager.save(installedNode); + }); + }); + + return installedPackage!; + } } diff --git a/packages/cli/src/services/communityPackage.service.ts b/packages/cli/src/services/communityPackage.service.ts new file mode 100644 index 0000000000..d373e12167 --- /dev/null +++ b/packages/cli/src/services/communityPackage.service.ts @@ -0,0 +1,306 @@ +import { exec } from 'child_process'; +import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises'; + +import { LoggerProxy as Logger } from 'n8n-workflow'; +import { UserSettings } from 'n8n-core'; +import { Service } from 'typedi'; +import { promisify } from 'util'; +import axios from 'axios'; + +import config from '@/config'; +import { toError } from '@/utils'; +import { InstalledPackagesRepository } from '@/databases/repositories/installedPackages.repository'; +import type { InstalledPackages } from '@/databases/entities/InstalledPackages'; +import { + NODE_PACKAGE_PREFIX, + NPM_COMMAND_TOKENS, + NPM_PACKAGE_STATUS_GOOD, + RESPONSE_ERROR_MESSAGES, + UNKNOWN_FAILURE_REASON, +} from '@/constants'; + +import type { PublicInstalledPackage } from 'n8n-workflow'; +import type { PackageDirectoryLoader } from 'n8n-core'; +import type { CommunityPackages } from '@/Interfaces'; +import type { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; + +const { + PACKAGE_NAME_NOT_PROVIDED, + DISK_IS_FULL, + PACKAGE_FAILED_TO_INSTALL, + PACKAGE_VERSION_NOT_FOUND, + PACKAGE_NOT_FOUND, +} = RESPONSE_ERROR_MESSAGES; + +const { + NPM_PACKAGE_NOT_FOUND_ERROR, + NPM_NO_VERSION_AVAILABLE, + NPM_DISK_NO_SPACE, + NPM_DISK_INSUFFICIENT_SPACE, + NPM_PACKAGE_VERSION_NOT_FOUND_ERROR, +} = NPM_COMMAND_TOKENS; + +const asyncExec = promisify(exec); + +const INVALID_OR_SUSPICIOUS_PACKAGE_NAME = /[^0-9a-z@\-./]/; + +@Service() +export class CommunityPackageService { + constructor(private readonly installedPackageRepository: InstalledPackagesRepository) {} + + async findInstalledPackage(packageName: string) { + return this.installedPackageRepository.findOne({ + where: { packageName }, + relations: ['installedNodes'], + }); + } + + async isPackageInstalled(packageName: string) { + return this.installedPackageRepository.exist({ where: { packageName } }); + } + + async getAllInstalledPackages() { + return this.installedPackageRepository.find({ relations: ['installedNodes'] }); + } + + async removePackageFromDatabase(packageName: InstalledPackages) { + return this.installedPackageRepository.remove(packageName); + } + + async persistInstalledPackage(packageLoader: PackageDirectoryLoader) { + try { + return await this.installedPackageRepository.saveInstalledPackageWithNodes(packageLoader); + } catch (maybeError) { + const error = toError(maybeError); + + Logger.error('Failed to save installed packages and nodes', { + error, + packageName: packageLoader.packageJson.name, + }); + + throw error; + } + } + + parseNpmPackageName(rawString?: string): CommunityPackages.ParsedPackageName { + if (!rawString) throw new Error(PACKAGE_NAME_NOT_PROVIDED); + + if (INVALID_OR_SUSPICIOUS_PACKAGE_NAME.test(rawString)) { + throw new Error('Package name must be a single word'); + } + + const scope = rawString.includes('/') ? rawString.split('/')[0] : undefined; + + const packageNameWithoutScope = scope ? rawString.replace(`${scope}/`, '') : rawString; + + if (!packageNameWithoutScope.startsWith(NODE_PACKAGE_PREFIX)) { + throw new Error(`Package name must start with ${NODE_PACKAGE_PREFIX}`); + } + + const version = packageNameWithoutScope.includes('@') + ? packageNameWithoutScope.split('@')[1] + : undefined; + + const packageName = version ? rawString.replace(`@${version}`, '') : rawString; + + return { packageName, scope, version, rawString }; + } + + async executeNpmCommand(command: string, options?: { doNotHandleError?: boolean }) { + const downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath(); + + const execOptions = { + cwd: downloadFolder, + env: { + NODE_PATH: process.env.NODE_PATH, + PATH: process.env.PATH, + APPDATA: process.env.APPDATA, + }, + }; + + try { + await fsAccess(downloadFolder); + } catch { + await fsMkdir(downloadFolder); + // Also init the folder since some versions + // of npm complain if the folder is empty + await asyncExec('npm init -y', execOptions); + } + + try { + const commandResult = await asyncExec(command, execOptions); + + return commandResult.stdout; + } catch (error) { + if (options?.doNotHandleError) throw error; + + const errorMessage = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON; + + const map = { + [NPM_PACKAGE_NOT_FOUND_ERROR]: PACKAGE_NOT_FOUND, + [NPM_NO_VERSION_AVAILABLE]: PACKAGE_NOT_FOUND, + [NPM_PACKAGE_VERSION_NOT_FOUND_ERROR]: PACKAGE_VERSION_NOT_FOUND, + [NPM_DISK_NO_SPACE]: DISK_IS_FULL, + [NPM_DISK_INSUFFICIENT_SPACE]: DISK_IS_FULL, + }; + + Object.entries(map).forEach(([npmMessage, n8nMessage]) => { + if (errorMessage.includes(npmMessage)) throw new Error(n8nMessage); + }); + + Logger.warn('npm command failed', { errorMessage }); + + throw new Error(PACKAGE_FAILED_TO_INSTALL); + } + } + + matchPackagesWithUpdates( + packages: InstalledPackages[], + updates?: CommunityPackages.AvailableUpdates, + ) { + if (!updates) return packages; + + return packages.reduce((acc, cur) => { + const publicPackage: PublicInstalledPackage = { ...cur }; + + const update = updates[cur.packageName]; + + if (update) publicPackage.updateAvailable = update.latest; + + acc.push(publicPackage); + + return acc; + }, []); + } + + matchMissingPackages(installedPackages: PublicInstalledPackage[], missingPackages: string) { + const missingPackagesList = missingPackages + .split(' ') + .map((name) => { + try { + // Strip away versions but maintain scope and package name + const parsedPackageData = this.parseNpmPackageName(name); + return parsedPackageData.packageName; + } catch { + return; + } + }) + .filter((i): i is string => i !== undefined); + + const hydratedPackageList: PublicInstalledPackage[] = []; + + installedPackages.forEach((installedPackage) => { + const hydratedInstalledPackage = { ...installedPackage }; + + if (missingPackagesList.includes(hydratedInstalledPackage.packageName)) { + hydratedInstalledPackage.failedLoading = true; + } + + hydratedPackageList.push(hydratedInstalledPackage); + }); + + return hydratedPackageList; + } + + async checkNpmPackageStatus(packageName: string) { + const N8N_BACKEND_SERVICE_URL = 'https://api.n8n.io/api/package'; + + try { + const response = await axios.post( + N8N_BACKEND_SERVICE_URL, + { name: packageName }, + { method: 'POST' }, + ); + + if (response.data.status !== NPM_PACKAGE_STATUS_GOOD) return response.data; + } catch { + // service unreachable, do nothing + } + + return { status: NPM_PACKAGE_STATUS_GOOD }; + } + + hasPackageLoaded(packageName: string) { + const missingPackages = config.get('nodes.packagesMissing') as string | undefined; + + if (!missingPackages) return true; + + return !missingPackages + .split(' ') + .some( + (packageNameAndVersion) => + packageNameAndVersion.startsWith(packageName) && + packageNameAndVersion.replace(packageName, '').startsWith('@'), + ); + } + + removePackageFromMissingList(packageName: string) { + try { + const failedPackages = config.get('nodes.packagesMissing').split(' '); + + const packageFailedToLoad = failedPackages.filter( + (packageNameAndVersion) => + !packageNameAndVersion.startsWith(packageName) || + !packageNameAndVersion.replace(packageName, '').startsWith('@'), + ); + + config.set('nodes.packagesMissing', packageFailedToLoad.join(' ')); + } catch { + // do nothing + } + } + + async setMissingPackages( + loadNodesAndCredentials: LoadNodesAndCredentials, + { reinstallMissingPackages }: { reinstallMissingPackages: boolean }, + ) { + const installedPackages = await this.getAllInstalledPackages(); + const missingPackages = new Set<{ packageName: string; version: string }>(); + + installedPackages.forEach((installedPackage) => { + installedPackage.installedNodes.forEach((installedNode) => { + if (!loadNodesAndCredentials.known.nodes[installedNode.type]) { + // Leave the list ready for installing in case we need. + missingPackages.add({ + packageName: installedPackage.packageName, + version: installedPackage.installedVersion, + }); + } + }); + }); + + config.set('nodes.packagesMissing', ''); + + if (missingPackages.size === 0) return; + + Logger.error( + 'n8n detected that some packages are missing. For more information, visit https://docs.n8n.io/integrations/community-nodes/troubleshooting/', + ); + + if (reinstallMissingPackages || process.env.N8N_REINSTALL_MISSING_PACKAGES) { + Logger.info('Attempting to reinstall missing packages', { missingPackages }); + try { + // Optimistic approach - stop if any installation fails + + for (const missingPackage of missingPackages) { + await loadNodesAndCredentials.installNpmModule( + missingPackage.packageName, + missingPackage.version, + ); + + missingPackages.delete(missingPackage); + } + Logger.info('Packages reinstalled successfully. Resuming regular initialization.'); + } catch (error) { + Logger.error('n8n was unable to install the missing packages.'); + } + } + + config.set( + 'nodes.packagesMissing', + Array.from(missingPackages) + .map((missingPackage) => `${missingPackage.packageName}@${missingPackage.version}`) + .join(' '), + ); + } +} diff --git a/packages/cli/test/integration/audit/nodes.risk.test.ts b/packages/cli/test/integration/audit/nodes.risk.test.ts index 49fb65bbc0..fb966d2369 100644 --- a/packages/cli/test/integration/audit/nodes.risk.test.ts +++ b/packages/cli/test/integration/audit/nodes.risk.test.ts @@ -1,7 +1,6 @@ import { v4 as uuid } from 'uuid'; import * as Db from '@/Db'; import { audit } from '@/audit'; -import * as packageModel from '@/CommunityNodes/packageModel'; import { OFFICIAL_RISKY_NODE_TYPES, NODES_REPORT } from '@/audit/constants'; import { getRiskSection, MOCK_PACKAGE, saveManualTriggerWorkflow } from './utils'; import * as testDb from '../shared/testDb'; @@ -9,10 +8,14 @@ import { toReportTitle } from '@/audit/utils'; import { mockInstance } from '../shared/utils/'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { NodeTypes } from '@/NodeTypes'; +import { CommunityPackageService } from '@/services/communityPackage.service'; +import Container from 'typedi'; const nodesAndCredentials = mockInstance(LoadNodesAndCredentials); nodesAndCredentials.getCustomDirectories.mockReturnValue([]); mockInstance(NodeTypes); +const communityPackageService = mockInstance(CommunityPackageService); +Container.set(CommunityPackageService, communityPackageService); beforeAll(async () => { await testDb.init(); @@ -24,9 +27,11 @@ beforeEach(async () => { afterAll(async () => { await testDb.terminate(); + jest.resetAllMocks(); }); test('should report risky official nodes', async () => { + communityPackageService.getAllInstalledPackages.mockResolvedValue(MOCK_PACKAGE); const map = [...OFFICIAL_RISKY_NODE_TYPES].reduce<{ [nodeType: string]: string }>((acc, cur) => { return (acc[cur] = uuid()), acc; }, {}); @@ -71,6 +76,7 @@ test('should report risky official nodes', async () => { }); test('should not report non-risky official nodes', async () => { + communityPackageService.getAllInstalledPackages.mockResolvedValue(MOCK_PACKAGE); await saveManualTriggerWorkflow(); const testAudit = await audit(['nodes']); @@ -85,7 +91,7 @@ test('should not report non-risky official nodes', async () => { }); test('should report community nodes', async () => { - jest.spyOn(packageModel, 'getAllInstalledPackages').mockResolvedValueOnce(MOCK_PACKAGE); + communityPackageService.getAllInstalledPackages.mockResolvedValue(MOCK_PACKAGE); const testAudit = await audit(['nodes']); diff --git a/packages/cli/test/integration/nodes.api.test.ts b/packages/cli/test/integration/nodes.api.test.ts index c30035520f..5c1e42b697 100644 --- a/packages/cli/test/integration/nodes.api.test.ts +++ b/packages/cli/test/integration/nodes.api.test.ts @@ -1,106 +1,104 @@ import path from 'path'; -import { mocked } from 'jest-mock'; -import type { SuperAgentTest } from 'supertest'; -import { - executeCommand, - checkNpmPackageStatus, - hasPackageLoaded, - removePackageFromMissingList, - isNpmError, -} from '@/CommunityNodes/helpers'; -import { findInstalledPackage, isPackageInstalled } from '@/CommunityNodes/packageModel'; + import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; -import { InstalledPackages } from '@db/entities/InstalledPackages'; -import type { User } from '@db/entities/User'; -import type { InstalledNodes } from '@db/entities/InstalledNodes'; -import { NodeTypes } from '@/NodeTypes'; import { Push } from '@/push'; +import { CommunityPackageService } from '@/services/communityPackage.service'; + import { COMMUNITY_PACKAGE_VERSION } from './shared/constants'; -import * as utils from './shared/utils/'; import * as testDb from './shared/testDb'; +import { + mockInstance, + setupTestServer, + mockPackage, + mockNode, + mockPackageName, +} from './shared/utils'; -const mockLoadNodesAndCredentials = utils.mockInstance(LoadNodesAndCredentials); -utils.mockInstance(NodeTypes); -utils.mockInstance(Push); +import type { InstalledPackages } from '@db/entities/InstalledPackages'; +import type { InstalledNodes } from '@db/entities/InstalledNodes'; +import type { SuperAgentTest } from 'supertest'; -jest.mock('@/CommunityNodes/helpers', () => { - return { - ...jest.requireActual('@/CommunityNodes/helpers'), - checkNpmPackageStatus: jest.fn(), - executeCommand: jest.fn(), - hasPackageLoaded: jest.fn(), - isNpmError: jest.fn(), - removePackageFromMissingList: jest.fn(), - }; -}); +const communityPackageService = mockInstance(CommunityPackageService); +const mockLoadNodesAndCredentials = mockInstance(LoadNodesAndCredentials); +mockInstance(Push); -jest.mock('@/CommunityNodes/packageModel', () => { - return { - ...jest.requireActual('@/CommunityNodes/packageModel'), - isPackageInstalled: jest.fn(), - findInstalledPackage: jest.fn(), - }; -}); +const testServer = setupTestServer({ endpointGroups: ['nodes'] }); -const mockedEmptyPackage = mocked(utils.emptyPackage); +const commonUpdatesProps = { + createdAt: new Date(), + updatedAt: new Date(), + installedVersion: COMMUNITY_PACKAGE_VERSION.CURRENT, + updateAvailable: COMMUNITY_PACKAGE_VERSION.UPDATED, +}; -const testServer = utils.setupTestServer({ endpointGroups: ['nodes'] }); +const parsedNpmPackageName = { + packageName: 'test', + rawString: 'test', +}; -let ownerShell: User; -let authOwnerShellAgent: SuperAgentTest; +let authAgent: SuperAgentTest; beforeAll(async () => { - const globalOwnerRole = await testDb.getGlobalOwnerRole(); - ownerShell = await testDb.createUserShell(globalOwnerRole); - authOwnerShellAgent = testServer.authAgentFor(ownerShell); + const ownerShell = await testDb.createOwner(); + authAgent = testServer.authAgentFor(ownerShell); }); -beforeEach(async () => { - await testDb.truncate(['InstalledNodes', 'InstalledPackages']); - - mocked(executeCommand).mockReset(); - mocked(findInstalledPackage).mockReset(); +beforeEach(() => { + jest.resetAllMocks(); }); describe('GET /nodes', () => { test('should respond 200 if no nodes are installed', async () => { + communityPackageService.getAllInstalledPackages.mockResolvedValue([]); const { - statusCode, body: { data }, - } = await authOwnerShellAgent.get('/nodes'); + } = await authAgent.get('/nodes').expect(200); - expect(statusCode).toBe(200); expect(data).toHaveLength(0); }); test('should return list of one installed package and node', async () => { - const { packageName } = await testDb.saveInstalledPackage(utils.installedPackagePayload()); - await testDb.saveInstalledNode(utils.installedNodePayload(packageName)); + const pkg = mockPackage(); + const node = mockNode(pkg.packageName); + pkg.installedNodes = [node]; + communityPackageService.getAllInstalledPackages.mockResolvedValue([pkg]); + communityPackageService.matchPackagesWithUpdates.mockReturnValue([pkg]); const { - statusCode, body: { data }, - } = await authOwnerShellAgent.get('/nodes'); + } = await authAgent.get('/nodes').expect(200); - expect(statusCode).toBe(200); expect(data).toHaveLength(1); expect(data[0].installedNodes).toHaveLength(1); }); test('should return list of multiple installed packages and nodes', async () => { - const first = await testDb.saveInstalledPackage(utils.installedPackagePayload()); - await testDb.saveInstalledNode(utils.installedNodePayload(first.packageName)); + const pkgA = mockPackage(); + const nodeA = mockNode(pkgA.packageName); - const second = await testDb.saveInstalledPackage(utils.installedPackagePayload()); - await testDb.saveInstalledNode(utils.installedNodePayload(second.packageName)); - await testDb.saveInstalledNode(utils.installedNodePayload(second.packageName)); + const pkgB = mockPackage(); + const nodeB = mockNode(pkgB.packageName); + const nodeC = mockNode(pkgB.packageName); + + communityPackageService.getAllInstalledPackages.mockResolvedValue([pkgA, pkgB]); + + communityPackageService.matchPackagesWithUpdates.mockReturnValue([ + { + ...commonUpdatesProps, + packageName: pkgA.packageName, + installedNodes: [nodeA], + }, + { + ...commonUpdatesProps, + packageName: pkgB.packageName, + installedNodes: [nodeB, nodeC], + }, + ]); const { - statusCode, body: { data }, - } = await authOwnerShellAgent.get('/nodes'); + } = await authAgent.get('/nodes').expect(200); - expect(statusCode).toBe(200); expect(data).toHaveLength(2); const allNodes = data.reduce( @@ -112,166 +110,141 @@ describe('GET /nodes', () => { }); test('should not check for updates if no packages installed', async () => { - await authOwnerShellAgent.get('/nodes'); + await authAgent.get('/nodes'); - expect(mocked(executeCommand)).toHaveBeenCalledTimes(0); + expect(communityPackageService.executeNpmCommand).not.toHaveBeenCalled(); }); test('should check for updates if packages installed', async () => { - const { packageName } = await testDb.saveInstalledPackage(utils.installedPackagePayload()); - await testDb.saveInstalledNode(utils.installedNodePayload(packageName)); + communityPackageService.getAllInstalledPackages.mockResolvedValue([mockPackage()]); - await authOwnerShellAgent.get('/nodes'); + await authAgent.get('/nodes').expect(200); - expect(mocked(executeCommand)).toHaveBeenCalledWith('npm outdated --json', { - doNotHandleError: true, - }); + const args = ['npm outdated --json', { doNotHandleError: true }]; + + expect(communityPackageService.executeNpmCommand).toHaveBeenCalledWith(...args); }); test('should report package updates if available', async () => { - const { packageName } = await testDb.saveInstalledPackage(utils.installedPackagePayload()); - await testDb.saveInstalledNode(utils.installedNodePayload(packageName)); + const pkg = mockPackage(); + communityPackageService.getAllInstalledPackages.mockResolvedValue([pkg]); - mocked(executeCommand).mockImplementationOnce(() => { + communityPackageService.executeNpmCommand.mockImplementation(() => { throw { code: 1, stdout: JSON.stringify({ - [packageName]: { + [pkg.packageName]: { current: COMMUNITY_PACKAGE_VERSION.CURRENT, wanted: COMMUNITY_PACKAGE_VERSION.CURRENT, latest: COMMUNITY_PACKAGE_VERSION.UPDATED, - location: path.join('node_modules', packageName), + location: path.join('node_modules', pkg.packageName), }, }), }; }); - mocked(isNpmError).mockReturnValueOnce(true); + communityPackageService.matchPackagesWithUpdates.mockReturnValue([ + { + packageName: 'test', + installedNodes: [], + ...commonUpdatesProps, + }, + ]); const { body: { data }, - } = await authOwnerShellAgent.get('/nodes'); + } = await authAgent.get('/nodes').expect(200); - expect(data[0].installedVersion).toBe(COMMUNITY_PACKAGE_VERSION.CURRENT); - expect(data[0].updateAvailable).toBe(COMMUNITY_PACKAGE_VERSION.UPDATED); + const [returnedPkg] = data; + + expect(returnedPkg.installedVersion).toBe(COMMUNITY_PACKAGE_VERSION.CURRENT); + expect(returnedPkg.updateAvailable).toBe(COMMUNITY_PACKAGE_VERSION.UPDATED); }); }); describe('POST /nodes', () => { test('should reject if package name is missing', async () => { - const { statusCode } = await authOwnerShellAgent.post('/nodes'); - - expect(statusCode).toBe(400); + await authAgent.post('/nodes').expect(400); }); test('should reject if package is duplicate', async () => { - mocked(findInstalledPackage).mockResolvedValueOnce(new InstalledPackages()); - mocked(isPackageInstalled).mockResolvedValueOnce(true); - mocked(hasPackageLoaded).mockReturnValueOnce(true); + communityPackageService.findInstalledPackage.mockResolvedValue(mockPackage()); + communityPackageService.isPackageInstalled.mockResolvedValue(true); + communityPackageService.hasPackageLoaded.mockReturnValue(true); + communityPackageService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName); const { - statusCode, body: { message }, - } = await authOwnerShellAgent.post('/nodes').send({ - name: utils.installedPackagePayload().packageName, - }); + } = await authAgent.post('/nodes').send({ name: mockPackageName() }).expect(400); - expect(statusCode).toBe(400); expect(message).toContain('already installed'); }); test('should allow installing packages that could not be loaded', async () => { - mocked(findInstalledPackage).mockResolvedValueOnce(new InstalledPackages()); - mocked(hasPackageLoaded).mockReturnValueOnce(false); - mocked(checkNpmPackageStatus).mockResolvedValueOnce({ status: 'OK' }); + communityPackageService.findInstalledPackage.mockResolvedValue(mockPackage()); + communityPackageService.hasPackageLoaded.mockReturnValue(false); + communityPackageService.checkNpmPackageStatus.mockResolvedValue({ status: 'OK' }); + communityPackageService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName); + mockLoadNodesAndCredentials.installNpmModule.mockResolvedValue(mockPackage()); - mockLoadNodesAndCredentials.installNpmModule.mockImplementationOnce(mockedEmptyPackage); + await authAgent.post('/nodes').send({ name: mockPackageName() }).expect(200); - const { statusCode } = await authOwnerShellAgent.post('/nodes').send({ - name: utils.installedPackagePayload().packageName, - }); - - expect(statusCode).toBe(200); - expect(mocked(removePackageFromMissingList)).toHaveBeenCalled(); + expect(communityPackageService.removePackageFromMissingList).toHaveBeenCalled(); }); test('should not install a banned package', async () => { - mocked(checkNpmPackageStatus).mockResolvedValueOnce({ status: 'Banned' }); + communityPackageService.checkNpmPackageStatus.mockResolvedValue({ status: 'Banned' }); + communityPackageService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName); const { - statusCode, body: { message }, - } = await authOwnerShellAgent.post('/nodes').send({ - name: utils.installedPackagePayload().packageName, - }); + } = await authAgent.post('/nodes').send({ name: mockPackageName() }).expect(400); - expect(statusCode).toBe(400); expect(message).toContain('banned'); }); }); describe('DELETE /nodes', () => { test('should not delete if package name is empty', async () => { - const response = await authOwnerShellAgent.delete('/nodes'); - - expect(response.statusCode).toBe(400); + await authAgent.delete('/nodes').expect(400); }); test('should reject if package is not installed', async () => { const { - statusCode, body: { message }, - } = await authOwnerShellAgent.delete('/nodes').query({ - name: utils.installedPackagePayload().packageName, - }); + } = await authAgent.delete('/nodes').query({ name: mockPackageName() }).expect(400); - expect(statusCode).toBe(400); expect(message).toContain('not installed'); }); test('should uninstall package', async () => { - const removeSpy = mockLoadNodesAndCredentials.removeNpmModule.mockImplementationOnce(jest.fn()); + communityPackageService.findInstalledPackage.mockResolvedValue(mockPackage()); - mocked(findInstalledPackage).mockImplementationOnce(mockedEmptyPackage); + await authAgent.delete('/nodes').query({ name: mockPackageName() }).expect(200); - const { statusCode } = await authOwnerShellAgent.delete('/nodes').query({ - name: utils.installedPackagePayload().packageName, - }); - - expect(statusCode).toBe(200); - expect(removeSpy).toHaveBeenCalledTimes(1); + expect(mockLoadNodesAndCredentials.removeNpmModule).toHaveBeenCalledTimes(1); }); }); describe('PATCH /nodes', () => { test('should reject if package name is empty', async () => { - const response = await authOwnerShellAgent.patch('/nodes'); - - expect(response.statusCode).toBe(400); + await authAgent.patch('/nodes').expect(400); }); - test('reject if package is not installed', async () => { + test('should reject if package is not installed', async () => { const { - statusCode, body: { message }, - } = await authOwnerShellAgent.patch('/nodes').send({ - name: utils.installedPackagePayload().packageName, - }); + } = await authAgent.patch('/nodes').send({ name: mockPackageName() }).expect(400); - expect(statusCode).toBe(400); expect(message).toContain('not installed'); }); test('should update a package', async () => { - const updateSpy = - mockLoadNodesAndCredentials.updateNpmModule.mockImplementationOnce(mockedEmptyPackage); + communityPackageService.findInstalledPackage.mockResolvedValue(mockPackage()); + communityPackageService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName); - mocked(findInstalledPackage).mockImplementationOnce(mockedEmptyPackage); + await authAgent.patch('/nodes').send({ name: mockPackageName() }); - await authOwnerShellAgent.patch('/nodes').send({ - name: utils.installedPackagePayload().packageName, - }); - - expect(updateSpy).toHaveBeenCalledTimes(1); + expect(mockLoadNodesAndCredentials.updateNpmModule).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 6d1d572473..3186c3f64b 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -14,8 +14,6 @@ import { sqliteMigrations } from '@db/migrations/sqlite'; import { hashPassword } from '@/UserManagement/UserManagementHelper'; import { AuthIdentity } from '@db/entities/AuthIdentity'; import type { ExecutionEntity } from '@db/entities/ExecutionEntity'; -import { InstalledNodes } from '@db/entities/InstalledNodes'; -import { InstalledPackages } from '@db/entities/InstalledPackages'; import type { Role } from '@db/entities/Role'; import type { TagEntity } from '@db/entities/TagEntity'; import type { User } from '@db/entities/User'; @@ -23,13 +21,7 @@ import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { ICredentialsDb } from '@/Interfaces'; import { DB_INITIALIZATION_TIMEOUT } from './constants'; import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random'; -import type { - CollectionName, - CredentialPayload, - InstalledNodePayload, - InstalledPackagePayload, - PostgresSchemaSection, -} from './types'; +import type { CollectionName, CredentialPayload, PostgresSchemaSection } from './types'; import type { ExecutionData } from '@db/entities/ExecutionData'; import { generateNanoId } from '@db/utils/generators'; import { RoleService } from '@/services/role.service'; @@ -292,31 +284,6 @@ export async function createManyUsers( return Db.collections.User.save(users); } -// -------------------------------------- -// Installed nodes and packages creation -// -------------------------------------- - -export async function saveInstalledPackage( - installedPackagePayload: InstalledPackagePayload, -): Promise { - const newInstalledPackage = new InstalledPackages(); - - Object.assign(newInstalledPackage, installedPackagePayload); - - const savedInstalledPackage = await Db.collections.InstalledPackages.save(newInstalledPackage); - return savedInstalledPackage; -} - -export async function saveInstalledNode( - installedNodePayload: InstalledNodePayload, -): Promise { - const newInstalledNode = new InstalledNodes(); - - Object.assign(newInstalledNode, installedNodePayload); - - return Db.collections.InstalledNodes.save(newInstalledNode); -} - export async function addApiKey(user: User): Promise { user.apiKey = randomApiKey(); return Db.collections.User.save(user); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 1e4e20c6f0..3fd972a6bd 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -59,15 +59,3 @@ export type SaveCredentialFunction = ( export type PostgresSchemaSection = { [K in 'host' | 'port' | 'schema' | 'user' | 'password']: { env: string }; }; - -export type InstalledPackagePayload = { - packageName: string; - installedVersion: string; -}; - -export type InstalledNodePayload = { - name: string; - type: string; - latestVersion: number; - package: string; -}; diff --git a/packages/cli/test/integration/shared/utils/communityNodes.ts b/packages/cli/test/integration/shared/utils/communityNodes.ts index cff99bb90a..c5cf10591f 100644 --- a/packages/cli/test/integration/shared/utils/communityNodes.ts +++ b/packages/cli/test/integration/shared/utils/communityNodes.ts @@ -3,27 +3,44 @@ import { InstalledPackages } from '@db/entities/InstalledPackages'; import { randomName } from '../random'; import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '../constants'; -import type { InstalledNodePayload, InstalledPackagePayload } from '../types'; +import { InstalledNodesRepository, InstalledPackagesRepository } from '@/databases/repositories'; +import Container from 'typedi'; -export function installedPackagePayload(): InstalledPackagePayload { - return { - packageName: NODE_PACKAGE_PREFIX + randomName(), +export const mockPackageName = () => NODE_PACKAGE_PREFIX + randomName(); + +export const mockPackage = () => + Container.get(InstalledPackagesRepository).create({ + packageName: mockPackageName(), installedVersion: COMMUNITY_PACKAGE_VERSION.CURRENT, - }; -} + installedNodes: [], + }); -export function installedNodePayload(packageName: string): InstalledNodePayload { +export const mockNode = (packageName: string) => { const nodeName = randomName(); - return { + + return Container.get(InstalledNodesRepository).create({ name: nodeName, type: nodeName, - latestVersion: COMMUNITY_NODE_VERSION.CURRENT, - package: packageName, - }; -} + latestVersion: COMMUNITY_NODE_VERSION.CURRENT.toString(), + package: { packageName }, + }); +}; export const emptyPackage = async () => { const installedPackage = new InstalledPackages(); installedPackage.installedNodes = []; return installedPackage; }; + +export function mockPackagePair(): InstalledPackages[] { + const pkgA = mockPackage(); + const nodeA = mockNode(pkgA.packageName); + pkgA.installedNodes = [nodeA]; + + const pkgB = mockPackage(); + const nodeB1 = mockNode(pkgB.packageName); + const nodeB2 = mockNode(pkgB.packageName); + pkgB.installedNodes = [nodeB1, nodeB2]; + + return [pkgA, pkgB]; +} diff --git a/packages/cli/test/unit/CommunityNodeHelpers.test.ts b/packages/cli/test/unit/CommunityNodeHelpers.test.ts deleted file mode 100644 index 24cb663b60..0000000000 --- a/packages/cli/test/unit/CommunityNodeHelpers.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { exec } from 'child_process'; -import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises'; - -import axios from 'axios'; - -import { - checkNpmPackageStatus, - matchPackagesWithUpdates, - executeCommand, - parseNpmPackageName, - matchMissingPackages, - hasPackageLoaded, - removePackageFromMissingList, -} from '@/CommunityNodes/helpers'; -import { - NODE_PACKAGE_PREFIX, - NPM_COMMAND_TOKENS, - NPM_PACKAGE_STATUS_GOOD, - RESPONSE_ERROR_MESSAGES, -} from '@/constants'; -import { InstalledPackages } from '@db/entities/InstalledPackages'; -import { InstalledNodes } from '@db/entities/InstalledNodes'; -import { randomName } from '../integration/shared/random'; -import config from '@/config'; -import { installedPackagePayload, installedNodePayload } from '../integration/shared/utils/'; - -import type { CommunityPackages } from '@/Interfaces'; - -jest.mock('fs/promises'); -jest.mock('child_process'); -jest.mock('axios'); - -describe('parsePackageName', () => { - test('Should fail with empty package name', () => { - expect(() => parseNpmPackageName('')).toThrowError(); - }); - - test('Should fail with invalid package prefix name', () => { - expect(() => parseNpmPackageName('INVALID_PREFIX@123')).toThrowError(); - }); - - test('Should parse valid package name', () => { - const validPackageName = NODE_PACKAGE_PREFIX + 'cool-package-name'; - const parsed = parseNpmPackageName(validPackageName); - - expect(parsed.rawString).toBe(validPackageName); - expect(parsed.packageName).toBe(validPackageName); - expect(parsed.scope).toBeUndefined(); - expect(parsed.version).toBeUndefined(); - }); - - test('Should parse valid package name and version', () => { - const validPackageName = NODE_PACKAGE_PREFIX + 'cool-package-name'; - const validPackageVersion = '0.1.1'; - const fullPackageName = `${validPackageName}@${validPackageVersion}`; - const parsed = parseNpmPackageName(fullPackageName); - - expect(parsed.rawString).toBe(fullPackageName); - expect(parsed.packageName).toBe(validPackageName); - expect(parsed.scope).toBeUndefined(); - expect(parsed.version).toBe(validPackageVersion); - }); - - test('Should parse valid package name, scope and version', () => { - const validPackageScope = '@n8n'; - const validPackageName = NODE_PACKAGE_PREFIX + 'cool-package-name'; - const validPackageVersion = '0.1.1'; - const fullPackageName = `${validPackageScope}/${validPackageName}@${validPackageVersion}`; - const parsed = parseNpmPackageName(fullPackageName); - - expect(parsed.rawString).toBe(fullPackageName); - expect(parsed.packageName).toBe(`${validPackageScope}/${validPackageName}`); - expect(parsed.scope).toBe(validPackageScope); - expect(parsed.version).toBe(validPackageVersion); - }); -}); - -describe('executeCommand', () => { - beforeEach(() => { - // @ts-ignore - fsAccess.mockReset(); - // @ts-ignore - fsMkdir.mockReset(); - // @ts-ignore - exec.mockReset(); - }); - - test('Should call command with valid options', async () => { - // @ts-ignore - exec.mockImplementation((...args) => { - expect(args[1].cwd).toBeDefined(); - expect(args[1].env).toBeDefined(); - // PATH or NODE_PATH may be undefined depending on environment so we don't check for these keys. - const callbackFunction = args[args.length - 1]; - callbackFunction(null, { stdout: 'Done' }); - }); - - await executeCommand('ls'); - expect(fsAccess).toHaveBeenCalled(); - expect(exec).toHaveBeenCalled(); - expect(fsMkdir).toBeCalledTimes(0); - }); - - test('Should make sure folder exists', async () => { - // @ts-ignore - exec.mockImplementation((...args) => { - const callbackFunction = args[args.length - 1]; - callbackFunction(null, { stdout: 'Done' }); - }); - - await executeCommand('ls'); - expect(fsAccess).toHaveBeenCalled(); - expect(exec).toHaveBeenCalled(); - expect(fsMkdir).toBeCalledTimes(0); - }); - - test('Should try to create folder if it does not exist', async () => { - // @ts-ignore - exec.mockImplementation((...args) => { - const callbackFunction = args[args.length - 1]; - callbackFunction(null, { stdout: 'Done' }); - }); - - // @ts-ignore - fsAccess.mockImplementation(() => { - throw new Error('Folder does not exist.'); - }); - - await executeCommand('ls'); - expect(fsAccess).toHaveBeenCalled(); - expect(exec).toHaveBeenCalled(); - expect(fsMkdir).toHaveBeenCalled(); - }); - - test('Should throw especial error when package is not found', async () => { - // @ts-ignore - exec.mockImplementation((...args) => { - const callbackFunction = args[args.length - 1]; - callbackFunction( - new Error( - 'Something went wrong - ' + - NPM_COMMAND_TOKENS.NPM_PACKAGE_NOT_FOUND_ERROR + - '. Aborting.', - ), - ); - }); - - await expect(async () => executeCommand('ls')).rejects.toThrow( - RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND, - ); - - expect(fsAccess).toHaveBeenCalled(); - expect(exec).toHaveBeenCalled(); - expect(fsMkdir).toHaveBeenCalledTimes(0); - }); -}); - -describe('crossInformationPackage', () => { - test('Should return same list if availableUpdates is undefined', () => { - const fakePackages = generateListOfFakeInstalledPackages(); - const crossedData = matchPackagesWithUpdates(fakePackages); - expect(crossedData).toEqual(fakePackages); - }); - - test('Should correctly match update versions for packages', () => { - const fakePackages = generateListOfFakeInstalledPackages(); - - const updates: CommunityPackages.AvailableUpdates = { - [fakePackages[0].packageName]: { - current: fakePackages[0].installedVersion, - wanted: fakePackages[0].installedVersion, - latest: '0.2.0', - location: fakePackages[0].packageName, - }, - [fakePackages[1].packageName]: { - current: fakePackages[0].installedVersion, - wanted: fakePackages[0].installedVersion, - latest: '0.3.0', - location: fakePackages[0].packageName, - }, - }; - - const crossedData = matchPackagesWithUpdates(fakePackages, updates); - - // @ts-ignore - expect(crossedData[0].updateAvailable).toBe('0.2.0'); - // @ts-ignore - expect(crossedData[1].updateAvailable).toBe('0.3.0'); - }); - - test('Should correctly match update versions for single package', () => { - const fakePackages = generateListOfFakeInstalledPackages(); - - const updates: CommunityPackages.AvailableUpdates = { - [fakePackages[1].packageName]: { - current: fakePackages[0].installedVersion, - wanted: fakePackages[0].installedVersion, - latest: '0.3.0', - location: fakePackages[0].packageName, - }, - }; - - const crossedData = matchPackagesWithUpdates(fakePackages, updates); - - // @ts-ignore - expect(crossedData[0].updateAvailable).toBeUndefined(); - // @ts-ignore - expect(crossedData[1].updateAvailable).toBe('0.3.0'); - }); -}); - -describe('matchMissingPackages', () => { - test('Should not match failed packages that do not exist', () => { - const fakePackages = generateListOfFakeInstalledPackages(); - const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${NODE_PACKAGE_PREFIX}another-very-long-name-that-never-is-seen`; - const matchedPackages = matchMissingPackages(fakePackages, notFoundPackageList); - - expect(matchedPackages).toEqual(fakePackages); - expect(matchedPackages[0].failedLoading).toBeUndefined(); - expect(matchedPackages[1].failedLoading).toBeUndefined(); - }); - - test('Should match failed packages that should be present', () => { - const fakePackages = generateListOfFakeInstalledPackages(); - const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${fakePackages[0].packageName}@${fakePackages[0].installedVersion}`; - const matchedPackages = matchMissingPackages(fakePackages, notFoundPackageList); - - expect(matchedPackages[0].failedLoading).toBe(true); - expect(matchedPackages[1].failedLoading).toBeUndefined(); - }); - - test('Should match failed packages even if version is wrong', () => { - const fakePackages = generateListOfFakeInstalledPackages(); - const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${fakePackages[0].packageName}@123.456.789`; - const matchedPackages = matchMissingPackages(fakePackages, notFoundPackageList); - - expect(matchedPackages[0].failedLoading).toBe(true); - expect(matchedPackages[1].failedLoading).toBeUndefined(); - }); -}); - -describe('checkNpmPackageStatus', () => { - test('Should call axios.post', async () => { - const packageName = NODE_PACKAGE_PREFIX + randomName(); - await checkNpmPackageStatus(packageName); - expect(axios.post).toHaveBeenCalled(); - }); - - test('Should not fail if request fails', async () => { - const packageName = NODE_PACKAGE_PREFIX + randomName(); - axios.post = jest.fn(() => { - throw new Error('Something went wrong'); - }); - const result = await checkNpmPackageStatus(packageName); - expect(result.status).toBe(NPM_PACKAGE_STATUS_GOOD); - }); - - test('Should warn if package is banned', async () => { - const packageName = NODE_PACKAGE_PREFIX + randomName(); - // @ts-ignore - axios.post = jest.fn(() => { - return { data: { status: 'Banned', reason: 'Not good' } }; - }); - const result = await checkNpmPackageStatus(packageName); - expect(result.status).toBe('Banned'); - expect(result.reason).toBe('Not good'); - }); -}); - -describe('hasPackageLoadedSuccessfully', () => { - test('Should return true when failed package list does not exist', () => { - config.set('nodes.packagesMissing', undefined); - const result = hasPackageLoaded('package'); - expect(result).toBe(true); - }); - - test('Should return true when package is not in the list of missing packages', () => { - config.set('nodes.packagesMissing', 'packageA@0.1.0 packgeB@0.1.0'); - const result = hasPackageLoaded('packageC'); - expect(result).toBe(true); - }); - - test('Should return false when package is in the list of missing packages', () => { - config.set('nodes.packagesMissing', 'packageA@0.1.0 packgeB@0.1.0'); - const result = hasPackageLoaded('packageA'); - expect(result).toBe(false); - }); -}); - -describe('removePackageFromMissingList', () => { - test('Should do nothing if key does not exist', () => { - config.set('nodes.packagesMissing', undefined); - - removePackageFromMissingList('packageA'); - - const packageList = config.get('nodes.packagesMissing'); - expect(packageList).toBeUndefined(); - }); - - test('Should remove only correct package from list', () => { - config.set('nodes.packagesMissing', 'packageA@0.1.0 packageB@0.2.0 packageBB@0.2.0'); - - removePackageFromMissingList('packageB'); - - const packageList = config.get('nodes.packagesMissing'); - expect(packageList).toBe('packageA@0.1.0 packageBB@0.2.0'); - }); - - test('Should not remove if package is not in the list', () => { - const failedToLoadList = 'packageA@0.1.0 packageB@0.2.0 packageBB@0.2.0'; - config.set('nodes.packagesMissing', failedToLoadList); - - removePackageFromMissingList('packageC'); - - const packageList = config.get('nodes.packagesMissing'); - expect(packageList).toBe(failedToLoadList); - }); -}); - -/** - * Generate a list with 2 packages, one with a single node and another with 2 nodes - */ -function generateListOfFakeInstalledPackages(): InstalledPackages[] { - const fakeInstalledPackage1 = new InstalledPackages(); - Object.assign(fakeInstalledPackage1, installedPackagePayload()); - - const fakeInstalledNode1 = new InstalledNodes(); - Object.assign(fakeInstalledNode1, installedNodePayload(fakeInstalledPackage1.packageName)); - - fakeInstalledPackage1.installedNodes = [fakeInstalledNode1]; - - const fakeInstalledPackage2 = new InstalledPackages(); - Object.assign(fakeInstalledPackage2, installedPackagePayload()); - - const fakeInstalledNode2 = new InstalledNodes(); - Object.assign(fakeInstalledNode2, installedNodePayload(fakeInstalledPackage2.packageName)); - - const fakeInstalledNode3 = new InstalledNodes(); - Object.assign(fakeInstalledNode3, installedNodePayload(fakeInstalledPackage2.packageName)); - - fakeInstalledPackage2.installedNodes = [fakeInstalledNode2, fakeInstalledNode3]; - - return [fakeInstalledPackage1, fakeInstalledPackage2]; -} diff --git a/packages/cli/test/unit/services/communityPackage.service.test.ts b/packages/cli/test/unit/services/communityPackage.service.test.ts new file mode 100644 index 0000000000..e0b3f894bb --- /dev/null +++ b/packages/cli/test/unit/services/communityPackage.service.test.ts @@ -0,0 +1,357 @@ +import { exec } from 'child_process'; +import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises'; + +import axios from 'axios'; + +import { + NODE_PACKAGE_PREFIX, + NPM_COMMAND_TOKENS, + NPM_PACKAGE_STATUS_GOOD, + RESPONSE_ERROR_MESSAGES, +} from '@/constants'; +import { InstalledPackages } from '@db/entities/InstalledPackages'; +import { randomName } from '../../integration/shared/random'; +import config from '@/config'; +import { mockInstance, mockPackageName, mockPackagePair } from '../../integration/shared/utils'; +import { mocked } from 'jest-mock'; + +import type { CommunityPackages } from '@/Interfaces'; +import { CommunityPackageService } from '@/services/communityPackage.service'; +import { InstalledNodesRepository, InstalledPackagesRepository } from '@/databases/repositories'; +import Container from 'typedi'; +import { InstalledNodes } from '@/databases/entities/InstalledNodes'; +import { + COMMUNITY_NODE_VERSION, + COMMUNITY_PACKAGE_VERSION, +} from '../../integration/shared/constants'; +import type { PublicInstalledPackage } from 'n8n-workflow'; + +jest.mock('fs/promises'); +jest.mock('child_process'); +jest.mock('axios'); + +type ExecOptions = NonNullable[1]>; +type ExecCallback = NonNullable[2]>; + +const execMock = ((...args) => { + const cb = args[args.length - 1] as ExecCallback; + cb(null, 'Done', ''); +}) as typeof exec; + +describe('CommunityPackageService', () => { + const installedNodesRepository = mockInstance(InstalledNodesRepository); + Container.set(InstalledNodesRepository, installedNodesRepository); + + installedNodesRepository.create.mockImplementation(() => { + const nodeName = randomName(); + + return Object.assign(new InstalledNodes(), { + name: nodeName, + type: nodeName, + latestVersion: COMMUNITY_NODE_VERSION.CURRENT.toString(), + packageName: 'test', + }); + }); + + const installedPackageRepository = mockInstance(InstalledPackagesRepository); + + installedPackageRepository.create.mockImplementation(() => { + return Object.assign(new InstalledPackages(), { + packageName: mockPackageName(), + installedVersion: COMMUNITY_PACKAGE_VERSION.CURRENT, + }); + }); + + const communityPackageService = new CommunityPackageService(installedPackageRepository); + + beforeEach(() => { + config.load(config.default); + }); + + describe('parseNpmPackageName()', () => { + test('should fail with empty package name', () => { + expect(() => communityPackageService.parseNpmPackageName('')).toThrowError(); + }); + + test('should fail with invalid package prefix name', () => { + expect(() => + communityPackageService.parseNpmPackageName('INVALID_PREFIX@123'), + ).toThrowError(); + }); + + test('should parse valid package name', () => { + const name = mockPackageName(); + const parsed = communityPackageService.parseNpmPackageName(name); + + expect(parsed.rawString).toBe(name); + expect(parsed.packageName).toBe(name); + expect(parsed.scope).toBeUndefined(); + expect(parsed.version).toBeUndefined(); + }); + + test('should parse valid package name and version', () => { + const name = mockPackageName(); + const version = '0.1.1'; + const fullPackageName = `${name}@${version}`; + const parsed = communityPackageService.parseNpmPackageName(fullPackageName); + + expect(parsed.rawString).toBe(fullPackageName); + expect(parsed.packageName).toBe(name); + expect(parsed.scope).toBeUndefined(); + expect(parsed.version).toBe(version); + }); + + test('should parse valid package name, scope and version', () => { + const scope = '@n8n'; + const name = mockPackageName(); + const version = '0.1.1'; + const fullPackageName = `${scope}/${name}@${version}`; + const parsed = communityPackageService.parseNpmPackageName(fullPackageName); + + expect(parsed.rawString).toBe(fullPackageName); + expect(parsed.packageName).toBe(`${scope}/${name}`); + expect(parsed.scope).toBe(scope); + expect(parsed.version).toBe(version); + }); + }); + + describe('executeCommand()', () => { + beforeEach(() => { + mocked(fsAccess).mockReset(); + mocked(fsMkdir).mockReset(); + mocked(exec).mockReset(); + }); + + test('should call command with valid options', async () => { + const execMock = ((...args) => { + const arg = args[1] as ExecOptions; + expect(arg.cwd).toBeDefined(); + expect(arg.env).toBeDefined(); + // PATH or NODE_PATH may be undefined depending on environment so we don't check for these keys. + const cb = args[args.length - 1] as ExecCallback; + cb(null, 'Done', ''); + }) as typeof exec; + + mocked(exec).mockImplementation(execMock); + + await communityPackageService.executeNpmCommand('ls'); + + expect(fsAccess).toHaveBeenCalled(); + expect(exec).toHaveBeenCalled(); + expect(fsMkdir).toBeCalledTimes(0); + }); + + test('should make sure folder exists', async () => { + mocked(exec).mockImplementation(execMock); + + await communityPackageService.executeNpmCommand('ls'); + expect(fsAccess).toHaveBeenCalled(); + expect(exec).toHaveBeenCalled(); + expect(fsMkdir).toBeCalledTimes(0); + }); + + test('should try to create folder if it does not exist', async () => { + mocked(exec).mockImplementation(execMock); + mocked(fsAccess).mockImplementation(() => { + throw new Error('Folder does not exist.'); + }); + + await communityPackageService.executeNpmCommand('ls'); + + expect(fsAccess).toHaveBeenCalled(); + expect(exec).toHaveBeenCalled(); + expect(fsMkdir).toHaveBeenCalled(); + }); + + test('should throw especial error when package is not found', async () => { + const erroringExecMock = ((...args) => { + const cb = args[args.length - 1] as ExecCallback; + const msg = `Something went wrong - ${NPM_COMMAND_TOKENS.NPM_PACKAGE_NOT_FOUND_ERROR}. Aborting.`; + cb(new Error(msg), '', ''); + }) as typeof exec; + + mocked(exec).mockImplementation(erroringExecMock); + + const call = async () => communityPackageService.executeNpmCommand('ls'); + + await expect(call).rejects.toThrowError(RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND); + + expect(fsAccess).toHaveBeenCalled(); + expect(exec).toHaveBeenCalled(); + expect(fsMkdir).toHaveBeenCalledTimes(0); + }); + }); + + describe('crossInformationPackage()', () => { + test('should return same list if availableUpdates is undefined', () => { + const fakePkgs = mockPackagePair(); + + const crossedPkgs = communityPackageService.matchPackagesWithUpdates(fakePkgs); + + expect(crossedPkgs).toEqual(fakePkgs); + }); + + test('should correctly match update versions for packages', () => { + const [pkgA, pkgB] = mockPackagePair(); + + const updates: CommunityPackages.AvailableUpdates = { + [pkgA.packageName]: { + current: pkgA.installedVersion, + wanted: pkgA.installedVersion, + latest: '0.2.0', + location: pkgA.packageName, + }, + [pkgB.packageName]: { + current: pkgA.installedVersion, + wanted: pkgA.installedVersion, + latest: '0.3.0', + location: pkgA.packageName, + }, + }; + + const [crossedPkgA, crossedPkgB]: PublicInstalledPackage[] = + communityPackageService.matchPackagesWithUpdates([pkgA, pkgB], updates); + + expect(crossedPkgA.updateAvailable).toBe('0.2.0'); + expect(crossedPkgB.updateAvailable).toBe('0.3.0'); + }); + + test('should correctly match update versions for single package', () => { + const [pkgA, pkgB] = mockPackagePair(); + + const updates: CommunityPackages.AvailableUpdates = { + [pkgB.packageName]: { + current: pkgA.installedVersion, + wanted: pkgA.installedVersion, + latest: '0.3.0', + location: pkgA.packageName, + }, + }; + + const [crossedPkgA, crossedPkgB]: PublicInstalledPackage[] = + communityPackageService.matchPackagesWithUpdates([pkgA, pkgB], updates); + + expect(crossedPkgA.updateAvailable).toBeUndefined(); + expect(crossedPkgB.updateAvailable).toBe('0.3.0'); + }); + }); + + describe('matchMissingPackages()', () => { + test('should not match failed packages that do not exist', () => { + const fakePkgs = mockPackagePair(); + const notFoundPkgNames = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${NODE_PACKAGE_PREFIX}another-very-long-name-that-never-is-seen`; + + const matchedPackages = communityPackageService.matchMissingPackages( + fakePkgs, + notFoundPkgNames, + ); + + expect(matchedPackages).toEqual(fakePkgs); + + const [first, second] = matchedPackages; + + expect(first.failedLoading).toBeUndefined(); + expect(second.failedLoading).toBeUndefined(); + }); + + test('should match failed packages that should be present', () => { + const [pkgA, pkgB] = mockPackagePair(); + const notFoundPkgNames = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${pkgA.packageName}@${pkgA.installedVersion}`; + + const [matchedPkgA, matchedPkgB] = communityPackageService.matchMissingPackages( + [pkgA, pkgB], + notFoundPkgNames, + ); + + expect(matchedPkgA.failedLoading).toBe(true); + expect(matchedPkgB.failedLoading).toBeUndefined(); + }); + + test('should match failed packages even if version is wrong', () => { + const [pkgA, pkgB] = mockPackagePair(); + const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${pkgA.packageName}@123.456.789`; + const [matchedPkgA, matchedPkgB] = communityPackageService.matchMissingPackages( + [pkgA, pkgB], + notFoundPackageList, + ); + + expect(matchedPkgA.failedLoading).toBe(true); + expect(matchedPkgB.failedLoading).toBeUndefined(); + }); + }); + + describe('checkNpmPackageStatus()', () => { + test('should call axios.post', async () => { + await communityPackageService.checkNpmPackageStatus(mockPackageName()); + + expect(axios.post).toHaveBeenCalled(); + }); + + test('should not fail if request fails', async () => { + mocked(axios.post).mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + const result = await communityPackageService.checkNpmPackageStatus(mockPackageName()); + + expect(result.status).toBe(NPM_PACKAGE_STATUS_GOOD); + }); + + test('should warn if package is banned', async () => { + mocked(axios.post).mockResolvedValue({ data: { status: 'Banned', reason: 'Not good' } }); + + const result = (await communityPackageService.checkNpmPackageStatus( + mockPackageName(), + )) as CommunityPackages.PackageStatusCheck; + + expect(result.status).toBe('Banned'); + expect(result.reason).toBe('Not good'); + }); + }); + + describe('hasPackageLoadedSuccessfully()', () => { + test('should return true when failed package list does not exist', () => { + config.set('nodes.packagesMissing', undefined); + + expect(communityPackageService.hasPackageLoaded('package')).toBe(true); + }); + + test('should return true when package is not in the list of missing packages', () => { + config.set('nodes.packagesMissing', 'packageA@0.1.0 packageB@0.1.0'); + + expect(communityPackageService.hasPackageLoaded('packageC')).toBe(true); + }); + + test('should return false when package is in the list of missing packages', () => { + config.set('nodes.packagesMissing', 'packageA@0.1.0 packageB@0.1.0'); + + expect(communityPackageService.hasPackageLoaded('packageA')).toBe(false); + }); + }); + + describe('removePackageFromMissingList()', () => { + test('should do nothing if key does not exist', () => { + config.set('nodes.packagesMissing', undefined); + + communityPackageService.removePackageFromMissingList('packageA'); + + expect(config.get('nodes.packagesMissing')).toBeUndefined(); + }); + + test('should remove only correct package from list', () => { + config.set('nodes.packagesMissing', 'packageA@0.1.0 packageB@0.2.0 packageC@0.2.0'); + + communityPackageService.removePackageFromMissingList('packageB'); + + expect(config.get('nodes.packagesMissing')).toBe('packageA@0.1.0 packageC@0.2.0'); + }); + + test('should not remove if package is not in the list', () => { + const failedToLoadList = 'packageA@0.1.0 packageB@0.2.0 packageB@0.2.0'; + config.set('nodes.packagesMissing', failedToLoadList); + communityPackageService.removePackageFromMissingList('packageC'); + + expect(config.get('nodes.packagesMissing')).toBe(failedToLoadList); + }); + }); +});