refactor: Move community package logic to service (no-changelog) (#6973)

This commit is contained in:
Iván Ovejero 2023-09-01 15:13:19 +02:00 committed by GitHub
parent 2432dcc661
commit 51093f649d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 923 additions and 951 deletions

View file

@ -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<string> => {
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<PublicInstalledPackage[]>((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<CommunityPackages.PackageStatusCheck> {
const N8N_BACKEND_SERVICE_URL = 'https://api.n8n.io/api/package';
try {
const response = await axios.post<CommunityPackages.PackageStatusCheck>(
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;
}

View file

@ -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<InstalledPackages | null> {
return Db.collections.InstalledPackages.findOne({
where: { packageName },
relations: ['installedNodes'],
});
}
export async function isPackageInstalled(packageName: string): Promise<boolean> {
return Db.collections.InstalledPackages.exist({
where: { packageName },
});
}
export async function getAllInstalledPackages(): Promise<InstalledPackages[]> {
return Db.collections.InstalledPackages.find({ relations: ['installedNodes'] });
}
export async function removePackageFromDatabase(
packageName: InstalledPackages,
): Promise<InstalledPackages> {
return Db.collections.InstalledPackages.remove(packageName);
}
export async function persistInstalledPackageData(
packageLoader: PackageDirectoryLoader,
): Promise<InstalledPackages> {
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<InstalledPackages>(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<InstalledNodes>(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;
}
}

View file

@ -23,7 +23,7 @@ import { mkdir } from 'fs/promises';
import path from 'path'; import path from 'path';
import config from '@/config'; import config from '@/config';
import type { InstalledPackages } from '@db/entities/InstalledPackages'; import type { InstalledPackages } from '@db/entities/InstalledPackages';
import { executeCommand } from '@/CommunityNodes/helpers'; import { CommunityPackageService } from './services/communityPackage.service';
import { import {
GENERATED_STATIC_DIR, GENERATED_STATIC_DIR,
RESPONSE_ERROR_MESSAGES, RESPONSE_ERROR_MESSAGES,
@ -34,7 +34,7 @@ import {
inE2ETests, inE2ETests,
} from '@/constants'; } from '@/constants';
import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { CredentialsOverwrites } from '@/CredentialsOverwrites';
import { Service } from 'typedi'; import Container, { Service } from 'typedi';
@Service() @Service()
export class LoadNodesAndCredentials implements INodesAndCredentials { export class LoadNodesAndCredentials implements INodesAndCredentials {
@ -189,8 +189,10 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
? `npm update ${packageName}` ? `npm update ${packageName}`
: `npm install ${packageName}${options.version ? `@${options.version}` : ''}`; : `npm install ${packageName}${options.version ? `@${options.version}` : ''}`;
const communityPackageService = Container.get(CommunityPackageService);
try { try {
await executeCommand(command); await communityPackageService.executeNpmCommand(command);
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) { if (error instanceof Error && error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) {
throw new Error(`The npm package "${packageName}" could not be 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 // Remove this package since loading it failed
const removeCommand = `npm remove ${packageName}`; const removeCommand = `npm remove ${packageName}`;
try { try {
await executeCommand(removeCommand); await communityPackageService.executeNpmCommand(removeCommand);
} catch {} } catch {}
throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_LOADING_FAILED, { cause: error }); 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) { if (loader.loadedNodes.length > 0) {
// Save info to DB // Save info to DB
try { try {
const { persistInstalledPackageData, removePackageFromDatabase } = await import( if (isUpdate) {
'@/CommunityNodes/packageModel' await communityPackageService.removePackageFromDatabase(options.installedPackage);
); }
if (isUpdate) await removePackageFromDatabase(options.installedPackage); const installedPackage = await communityPackageService.persistInstalledPackage(loader);
const installedPackage = await persistInstalledPackageData(loader);
await this.postProcessLoaders(); await this.postProcessLoaders();
await this.generateTypesForFrontend(); await this.generateTypesForFrontend();
return installedPackage; return installedPackage;
@ -234,7 +235,7 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
// Remove this package since it contains no loadable nodes // Remove this package since it contains no loadable nodes
const removeCommand = `npm remove ${packageName}`; const removeCommand = `npm remove ${packageName}`;
try { try {
await executeCommand(removeCommand); await communityPackageService.executeNpmCommand(removeCommand);
} catch {} } catch {}
throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES); 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<void> { async removeNpmModule(packageName: string, installedPackage: InstalledPackages): Promise<void> {
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 communityPackageService.removePackageFromDatabase(installedPackage);
await removePackageFromDatabase(installedPackage);
if (packageName in this.loaders) { if (packageName in this.loaders) {
this.loaders[packageName].reset(); this.loaders[packageName].reset();

View file

@ -2,7 +2,7 @@ import * as path from 'path';
import glob from 'fast-glob'; import glob from 'fast-glob';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { getNodeTypes } from '@/audit/utils'; import { getNodeTypes } from '@/audit/utils';
import { getAllInstalledPackages } from '@/CommunityNodes/packageModel'; import { CommunityPackageService } from '@/services/communityPackage.service';
import { import {
OFFICIAL_RISKY_NODE_TYPES, OFFICIAL_RISKY_NODE_TYPES,
ENV_VARS_DOCS_URL, ENV_VARS_DOCS_URL,
@ -15,7 +15,7 @@ import type { Risk } from '@/audit/types';
import { Container } from 'typedi'; import { Container } from 'typedi';
async function getCommunityNodeDetails() { async function getCommunityNodeDetails() {
const installedPackages = await getAllInstalledPackages(); const installedPackages = await Container.get(CommunityPackageService).getAllInstalledPackages();
return installedPackages.reduce<Risk.CommunityNodeDetails[]>((acc, pkg) => { return installedPackages.reduce<Risk.CommunityNodeDetails[]>((acc, pkg) => {
pkg.installedNodes.forEach((node) => pkg.installedNodes.forEach((node) =>

View file

@ -13,7 +13,7 @@ import replaceStream from 'replacestream';
import { promisify } from 'util'; import { promisify } from 'util';
import glob from 'fast-glob'; import glob from 'fast-glob';
import { LoggerProxy, sleep, jsonParse } from 'n8n-workflow'; import { sleep, jsonParse } from 'n8n-workflow';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import config from '@/config'; import config from '@/config';
@ -23,7 +23,7 @@ import * as Db from '@/Db';
import * as GenericHelpers from '@/GenericHelpers'; import * as GenericHelpers from '@/GenericHelpers';
import { Server } from '@/Server'; import { Server } from '@/Server';
import { TestWebhooks } from '@/TestWebhooks'; 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 { EDITOR_UI_DIST_DIR, GENERATED_STATIC_DIR } from '@/constants';
import { eventBus } from '@/eventbus'; import { eventBus } from '@/eventbus';
import { BaseCommand } from './BaseCommand'; import { BaseCommand } from './BaseCommand';
@ -233,54 +233,12 @@ export class Start extends BaseCommand {
const areCommunityPackagesEnabled = config.getEnv('nodes.communityPackages.enabled'); const areCommunityPackagesEnabled = config.getEnv('nodes.communityPackages.enabled');
if (areCommunityPackagesEnabled) { if (areCommunityPackagesEnabled) {
const installedPackages = await getAllInstalledPackages(); await Container.get(CommunityPackageService).setMissingPackages(
const missingPackages = new Set<{ this.loadNodesAndCredentials,
packageName: string; {
version: string; reinstallMissingPackages: flags.reinstallMissingPackages,
}>(); },
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(' '),
);
}
} }
const dbType = config.getEnv('database.type'); const dbType = config.getEnv('database.type');

View file

@ -7,41 +7,45 @@ import {
import { Authorized, Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators'; import { Authorized, Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators';
import { NodeRequest } from '@/requests'; import { NodeRequest } from '@/requests';
import { BadRequestError, InternalServerError } from '@/ResponseHelper'; 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 { InstalledPackages } from '@db/entities/InstalledPackages';
import type { CommunityPackages } from '@/Interfaces'; import type { CommunityPackages } from '@/Interfaces';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { Push } from '@/push'; import { Push } from '@/push';
import { Config } from '@/config'; 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']) @Authorized(['global', 'owner'])
@RestController('/nodes') @RestController('/nodes')
export class NodesController { export class NodesController {
private communityPackageService: CommunityPackageService;
constructor( constructor(
private config: Config, private config: Config,
private loadNodesAndCredentials: LoadNodesAndCredentials, private loadNodesAndCredentials: LoadNodesAndCredentials,
private push: Push, private push: Push,
private internalHooks: InternalHooks, private internalHooks: InternalHooks,
) {} ) {
this.communityPackageService = Container.get(CommunityPackageService);
}
// TODO: move this into a new decorator `@IfConfig('executions.mode', 'queue')` // TODO: move this into a new decorator `@IfConfig('executions.mode', 'queue')`
@Middleware() @Middleware()
@ -65,7 +69,7 @@ export class NodesController {
let parsed: CommunityPackages.ParsedPackageName; let parsed: CommunityPackages.ParsedPackageName;
try { try {
parsed = parseNpmPackageName(name); parsed = this.communityPackageService.parseNpmPackageName(name);
} catch (error) { } catch (error) {
throw new BadRequestError( throw new BadRequestError(
error instanceof Error ? error.message : 'Failed to parse package name', error instanceof Error ? error.message : 'Failed to parse package name',
@ -81,8 +85,8 @@ export class NodesController {
); );
} }
const isInstalled = await isPackageInstalled(parsed.packageName); const isInstalled = await this.communityPackageService.isPackageInstalled(parsed.packageName);
const hasLoaded = hasPackageLoaded(name); const hasLoaded = this.communityPackageService.hasPackageLoaded(name);
if (isInstalled && hasLoaded) { if (isInstalled && hasLoaded) {
throw new BadRequestError( 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') { if (packageStatus.status !== 'OK') {
throw new BadRequestError(`Package "${name}" is banned so it cannot be installed`); 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); 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 // broadcast to connected frontends that node list has been updated
installedPackage.installedNodes.forEach((node) => { installedPackage.installedNodes.forEach((node) => {
@ -152,7 +156,7 @@ export class NodesController {
@Get('/') @Get('/')
async getInstalledPackages() { async getInstalledPackages() {
const installedPackages = await getAllInstalledPackages(); const installedPackages = await this.communityPackageService.getAllInstalledPackages();
if (installedPackages.length === 0) return []; if (installedPackages.length === 0) return [];
@ -160,7 +164,7 @@ export class NodesController {
try { try {
const command = ['npm', 'outdated', '--json'].join(' '); const command = ['npm', 'outdated', '--json'].join(' ');
await executeCommand(command, { doNotHandleError: true }); await this.communityPackageService.executeNpmCommand(command, { doNotHandleError: true });
} catch (error) { } catch (error) {
// when there are updates, npm exits with code 1 // when there are updates, npm exits with code 1
// when there are no updates, command succeeds // 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 { try {
const missingPackages = this.config.get('nodes.packagesMissing') as string | undefined; const missingPackages = this.config.get('nodes.packagesMissing') as string | undefined;
if (missingPackages) { if (missingPackages) {
hydratedPackages = matchMissingPackages(hydratedPackages, missingPackages); hydratedPackages = this.communityPackageService.matchMissingPackages(
hydratedPackages,
missingPackages,
);
} }
} catch {} } catch {}
@ -191,14 +201,14 @@ export class NodesController {
} }
try { try {
sanitizeNpmPackageName(name); this.communityPackageService.parseNpmPackageName(name); // sanitize input
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON; const message = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON;
throw new BadRequestError(message); throw new BadRequestError(message);
} }
const installedPackage = await findInstalledPackage(name); const installedPackage = await this.communityPackageService.findInstalledPackage(name);
if (!installedPackage) { if (!installedPackage) {
throw new BadRequestError(PACKAGE_NOT_INSTALLED); throw new BadRequestError(PACKAGE_NOT_INSTALLED);
@ -241,7 +251,9 @@ export class NodesController {
throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED); throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED);
} }
const previouslyInstalledPackage = await findInstalledPackage(name); const previouslyInstalledPackage = await this.communityPackageService.findInstalledPackage(
name,
);
if (!previouslyInstalledPackage) { if (!previouslyInstalledPackage) {
throw new BadRequestError(PACKAGE_NOT_INSTALLED); throw new BadRequestError(PACKAGE_NOT_INSTALLED);
@ -249,7 +261,7 @@ export class NodesController {
try { try {
const newInstalledPackage = await this.loadNodesAndCredentials.updateNpmModule( const newInstalledPackage = await this.loadNodesAndCredentials.updateNpmModule(
parseNpmPackageName(name).packageName, this.communityPackageService.parseNpmPackageName(name).packageName,
previouslyInstalledPackage, previouslyInstalledPackage,
); );

View file

@ -1,10 +1,50 @@
import { Service } from 'typedi'; import { Service } from 'typedi';
import { DataSource, Repository } from 'typeorm'; import { DataSource, Repository } from 'typeorm';
import { InstalledPackages } from '../entities/InstalledPackages'; import { InstalledPackages } from '../entities/InstalledPackages';
import { InstalledNodesRepository } from './installedNodes.repository';
import type { PackageDirectoryLoader } from 'n8n-core';
@Service() @Service()
export class InstalledPackagesRepository extends Repository<InstalledPackages> { export class InstalledPackagesRepository extends Repository<InstalledPackages> {
constructor(dataSource: DataSource) { constructor(
dataSource: DataSource,
private installedNodesRepository: InstalledNodesRepository,
) {
super(InstalledPackages, dataSource.manager); 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!;
}
} }

View file

@ -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<PublicInstalledPackage[]>((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<CommunityPackages.PackageStatusCheck>(
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(' '),
);
}
}

View file

@ -1,7 +1,6 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import * as Db from '@/Db'; import * as Db from '@/Db';
import { audit } from '@/audit'; import { audit } from '@/audit';
import * as packageModel from '@/CommunityNodes/packageModel';
import { OFFICIAL_RISKY_NODE_TYPES, NODES_REPORT } from '@/audit/constants'; import { OFFICIAL_RISKY_NODE_TYPES, NODES_REPORT } from '@/audit/constants';
import { getRiskSection, MOCK_PACKAGE, saveManualTriggerWorkflow } from './utils'; import { getRiskSection, MOCK_PACKAGE, saveManualTriggerWorkflow } from './utils';
import * as testDb from '../shared/testDb'; import * as testDb from '../shared/testDb';
@ -9,10 +8,14 @@ import { toReportTitle } from '@/audit/utils';
import { mockInstance } from '../shared/utils/'; import { mockInstance } from '../shared/utils/';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { NodeTypes } from '@/NodeTypes'; import { NodeTypes } from '@/NodeTypes';
import { CommunityPackageService } from '@/services/communityPackage.service';
import Container from 'typedi';
const nodesAndCredentials = mockInstance(LoadNodesAndCredentials); const nodesAndCredentials = mockInstance(LoadNodesAndCredentials);
nodesAndCredentials.getCustomDirectories.mockReturnValue([]); nodesAndCredentials.getCustomDirectories.mockReturnValue([]);
mockInstance(NodeTypes); mockInstance(NodeTypes);
const communityPackageService = mockInstance(CommunityPackageService);
Container.set(CommunityPackageService, communityPackageService);
beforeAll(async () => { beforeAll(async () => {
await testDb.init(); await testDb.init();
@ -24,9 +27,11 @@ beforeEach(async () => {
afterAll(async () => { afterAll(async () => {
await testDb.terminate(); await testDb.terminate();
jest.resetAllMocks();
}); });
test('should report risky official nodes', async () => { test('should report risky official nodes', async () => {
communityPackageService.getAllInstalledPackages.mockResolvedValue(MOCK_PACKAGE);
const map = [...OFFICIAL_RISKY_NODE_TYPES].reduce<{ [nodeType: string]: string }>((acc, cur) => { const map = [...OFFICIAL_RISKY_NODE_TYPES].reduce<{ [nodeType: string]: string }>((acc, cur) => {
return (acc[cur] = uuid()), acc; 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 () => { test('should not report non-risky official nodes', async () => {
communityPackageService.getAllInstalledPackages.mockResolvedValue(MOCK_PACKAGE);
await saveManualTriggerWorkflow(); await saveManualTriggerWorkflow();
const testAudit = await audit(['nodes']); const testAudit = await audit(['nodes']);
@ -85,7 +91,7 @@ test('should not report non-risky official nodes', async () => {
}); });
test('should report community 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']); const testAudit = await audit(['nodes']);

View file

@ -1,106 +1,104 @@
import path from 'path'; 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 { 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 { Push } from '@/push';
import { CommunityPackageService } from '@/services/communityPackage.service';
import { COMMUNITY_PACKAGE_VERSION } from './shared/constants'; import { COMMUNITY_PACKAGE_VERSION } from './shared/constants';
import * as utils from './shared/utils/';
import * as testDb from './shared/testDb'; import * as testDb from './shared/testDb';
import {
mockInstance,
setupTestServer,
mockPackage,
mockNode,
mockPackageName,
} from './shared/utils';
const mockLoadNodesAndCredentials = utils.mockInstance(LoadNodesAndCredentials); import type { InstalledPackages } from '@db/entities/InstalledPackages';
utils.mockInstance(NodeTypes); import type { InstalledNodes } from '@db/entities/InstalledNodes';
utils.mockInstance(Push); import type { SuperAgentTest } from 'supertest';
jest.mock('@/CommunityNodes/helpers', () => { const communityPackageService = mockInstance(CommunityPackageService);
return { const mockLoadNodesAndCredentials = mockInstance(LoadNodesAndCredentials);
...jest.requireActual('@/CommunityNodes/helpers'), mockInstance(Push);
checkNpmPackageStatus: jest.fn(),
executeCommand: jest.fn(),
hasPackageLoaded: jest.fn(),
isNpmError: jest.fn(),
removePackageFromMissingList: jest.fn(),
};
});
jest.mock('@/CommunityNodes/packageModel', () => { const testServer = setupTestServer({ endpointGroups: ['nodes'] });
return {
...jest.requireActual('@/CommunityNodes/packageModel'),
isPackageInstalled: jest.fn(),
findInstalledPackage: jest.fn(),
};
});
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 authAgent: SuperAgentTest;
let authOwnerShellAgent: SuperAgentTest;
beforeAll(async () => { beforeAll(async () => {
const globalOwnerRole = await testDb.getGlobalOwnerRole(); const ownerShell = await testDb.createOwner();
ownerShell = await testDb.createUserShell(globalOwnerRole); authAgent = testServer.authAgentFor(ownerShell);
authOwnerShellAgent = testServer.authAgentFor(ownerShell);
}); });
beforeEach(async () => { beforeEach(() => {
await testDb.truncate(['InstalledNodes', 'InstalledPackages']); jest.resetAllMocks();
mocked(executeCommand).mockReset();
mocked(findInstalledPackage).mockReset();
}); });
describe('GET /nodes', () => { describe('GET /nodes', () => {
test('should respond 200 if no nodes are installed', async () => { test('should respond 200 if no nodes are installed', async () => {
communityPackageService.getAllInstalledPackages.mockResolvedValue([]);
const { const {
statusCode,
body: { data }, body: { data },
} = await authOwnerShellAgent.get('/nodes'); } = await authAgent.get('/nodes').expect(200);
expect(statusCode).toBe(200);
expect(data).toHaveLength(0); expect(data).toHaveLength(0);
}); });
test('should return list of one installed package and node', async () => { test('should return list of one installed package and node', async () => {
const { packageName } = await testDb.saveInstalledPackage(utils.installedPackagePayload()); const pkg = mockPackage();
await testDb.saveInstalledNode(utils.installedNodePayload(packageName)); const node = mockNode(pkg.packageName);
pkg.installedNodes = [node];
communityPackageService.getAllInstalledPackages.mockResolvedValue([pkg]);
communityPackageService.matchPackagesWithUpdates.mockReturnValue([pkg]);
const { const {
statusCode,
body: { data }, body: { data },
} = await authOwnerShellAgent.get('/nodes'); } = await authAgent.get('/nodes').expect(200);
expect(statusCode).toBe(200);
expect(data).toHaveLength(1); expect(data).toHaveLength(1);
expect(data[0].installedNodes).toHaveLength(1); expect(data[0].installedNodes).toHaveLength(1);
}); });
test('should return list of multiple installed packages and nodes', async () => { test('should return list of multiple installed packages and nodes', async () => {
const first = await testDb.saveInstalledPackage(utils.installedPackagePayload()); const pkgA = mockPackage();
await testDb.saveInstalledNode(utils.installedNodePayload(first.packageName)); const nodeA = mockNode(pkgA.packageName);
const second = await testDb.saveInstalledPackage(utils.installedPackagePayload()); const pkgB = mockPackage();
await testDb.saveInstalledNode(utils.installedNodePayload(second.packageName)); const nodeB = mockNode(pkgB.packageName);
await testDb.saveInstalledNode(utils.installedNodePayload(second.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 { const {
statusCode,
body: { data }, body: { data },
} = await authOwnerShellAgent.get('/nodes'); } = await authAgent.get('/nodes').expect(200);
expect(statusCode).toBe(200);
expect(data).toHaveLength(2); expect(data).toHaveLength(2);
const allNodes = data.reduce( const allNodes = data.reduce(
@ -112,166 +110,141 @@ describe('GET /nodes', () => {
}); });
test('should not check for updates if no packages installed', async () => { 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 () => { test('should check for updates if packages installed', async () => {
const { packageName } = await testDb.saveInstalledPackage(utils.installedPackagePayload()); communityPackageService.getAllInstalledPackages.mockResolvedValue([mockPackage()]);
await testDb.saveInstalledNode(utils.installedNodePayload(packageName));
await authOwnerShellAgent.get('/nodes'); await authAgent.get('/nodes').expect(200);
expect(mocked(executeCommand)).toHaveBeenCalledWith('npm outdated --json', { const args = ['npm outdated --json', { doNotHandleError: true }];
doNotHandleError: true,
}); expect(communityPackageService.executeNpmCommand).toHaveBeenCalledWith(...args);
}); });
test('should report package updates if available', async () => { test('should report package updates if available', async () => {
const { packageName } = await testDb.saveInstalledPackage(utils.installedPackagePayload()); const pkg = mockPackage();
await testDb.saveInstalledNode(utils.installedNodePayload(packageName)); communityPackageService.getAllInstalledPackages.mockResolvedValue([pkg]);
mocked(executeCommand).mockImplementationOnce(() => { communityPackageService.executeNpmCommand.mockImplementation(() => {
throw { throw {
code: 1, code: 1,
stdout: JSON.stringify({ stdout: JSON.stringify({
[packageName]: { [pkg.packageName]: {
current: COMMUNITY_PACKAGE_VERSION.CURRENT, current: COMMUNITY_PACKAGE_VERSION.CURRENT,
wanted: COMMUNITY_PACKAGE_VERSION.CURRENT, wanted: COMMUNITY_PACKAGE_VERSION.CURRENT,
latest: COMMUNITY_PACKAGE_VERSION.UPDATED, 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 { const {
body: { data }, body: { data },
} = await authOwnerShellAgent.get('/nodes'); } = await authAgent.get('/nodes').expect(200);
expect(data[0].installedVersion).toBe(COMMUNITY_PACKAGE_VERSION.CURRENT); const [returnedPkg] = data;
expect(data[0].updateAvailable).toBe(COMMUNITY_PACKAGE_VERSION.UPDATED);
expect(returnedPkg.installedVersion).toBe(COMMUNITY_PACKAGE_VERSION.CURRENT);
expect(returnedPkg.updateAvailable).toBe(COMMUNITY_PACKAGE_VERSION.UPDATED);
}); });
}); });
describe('POST /nodes', () => { describe('POST /nodes', () => {
test('should reject if package name is missing', async () => { test('should reject if package name is missing', async () => {
const { statusCode } = await authOwnerShellAgent.post('/nodes'); await authAgent.post('/nodes').expect(400);
expect(statusCode).toBe(400);
}); });
test('should reject if package is duplicate', async () => { test('should reject if package is duplicate', async () => {
mocked(findInstalledPackage).mockResolvedValueOnce(new InstalledPackages()); communityPackageService.findInstalledPackage.mockResolvedValue(mockPackage());
mocked(isPackageInstalled).mockResolvedValueOnce(true); communityPackageService.isPackageInstalled.mockResolvedValue(true);
mocked(hasPackageLoaded).mockReturnValueOnce(true); communityPackageService.hasPackageLoaded.mockReturnValue(true);
communityPackageService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName);
const { const {
statusCode,
body: { message }, body: { message },
} = await authOwnerShellAgent.post('/nodes').send({ } = await authAgent.post('/nodes').send({ name: mockPackageName() }).expect(400);
name: utils.installedPackagePayload().packageName,
});
expect(statusCode).toBe(400);
expect(message).toContain('already installed'); expect(message).toContain('already installed');
}); });
test('should allow installing packages that could not be loaded', async () => { test('should allow installing packages that could not be loaded', async () => {
mocked(findInstalledPackage).mockResolvedValueOnce(new InstalledPackages()); communityPackageService.findInstalledPackage.mockResolvedValue(mockPackage());
mocked(hasPackageLoaded).mockReturnValueOnce(false); communityPackageService.hasPackageLoaded.mockReturnValue(false);
mocked(checkNpmPackageStatus).mockResolvedValueOnce({ status: 'OK' }); 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({ expect(communityPackageService.removePackageFromMissingList).toHaveBeenCalled();
name: utils.installedPackagePayload().packageName,
});
expect(statusCode).toBe(200);
expect(mocked(removePackageFromMissingList)).toHaveBeenCalled();
}); });
test('should not install a banned package', async () => { test('should not install a banned package', async () => {
mocked(checkNpmPackageStatus).mockResolvedValueOnce({ status: 'Banned' }); communityPackageService.checkNpmPackageStatus.mockResolvedValue({ status: 'Banned' });
communityPackageService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName);
const { const {
statusCode,
body: { message }, body: { message },
} = await authOwnerShellAgent.post('/nodes').send({ } = await authAgent.post('/nodes').send({ name: mockPackageName() }).expect(400);
name: utils.installedPackagePayload().packageName,
});
expect(statusCode).toBe(400);
expect(message).toContain('banned'); expect(message).toContain('banned');
}); });
}); });
describe('DELETE /nodes', () => { describe('DELETE /nodes', () => {
test('should not delete if package name is empty', async () => { test('should not delete if package name is empty', async () => {
const response = await authOwnerShellAgent.delete('/nodes'); await authAgent.delete('/nodes').expect(400);
expect(response.statusCode).toBe(400);
}); });
test('should reject if package is not installed', async () => { test('should reject if package is not installed', async () => {
const { const {
statusCode,
body: { message }, body: { message },
} = await authOwnerShellAgent.delete('/nodes').query({ } = await authAgent.delete('/nodes').query({ name: mockPackageName() }).expect(400);
name: utils.installedPackagePayload().packageName,
});
expect(statusCode).toBe(400);
expect(message).toContain('not installed'); expect(message).toContain('not installed');
}); });
test('should uninstall package', async () => { 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({ expect(mockLoadNodesAndCredentials.removeNpmModule).toHaveBeenCalledTimes(1);
name: utils.installedPackagePayload().packageName,
});
expect(statusCode).toBe(200);
expect(removeSpy).toHaveBeenCalledTimes(1);
}); });
}); });
describe('PATCH /nodes', () => { describe('PATCH /nodes', () => {
test('should reject if package name is empty', async () => { test('should reject if package name is empty', async () => {
const response = await authOwnerShellAgent.patch('/nodes'); await authAgent.patch('/nodes').expect(400);
expect(response.statusCode).toBe(400);
}); });
test('reject if package is not installed', async () => { test('should reject if package is not installed', async () => {
const { const {
statusCode,
body: { message }, body: { message },
} = await authOwnerShellAgent.patch('/nodes').send({ } = await authAgent.patch('/nodes').send({ name: mockPackageName() }).expect(400);
name: utils.installedPackagePayload().packageName,
});
expect(statusCode).toBe(400);
expect(message).toContain('not installed'); expect(message).toContain('not installed');
}); });
test('should update a package', async () => { test('should update a package', async () => {
const updateSpy = communityPackageService.findInstalledPackage.mockResolvedValue(mockPackage());
mockLoadNodesAndCredentials.updateNpmModule.mockImplementationOnce(mockedEmptyPackage); communityPackageService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName);
mocked(findInstalledPackage).mockImplementationOnce(mockedEmptyPackage); await authAgent.patch('/nodes').send({ name: mockPackageName() });
await authOwnerShellAgent.patch('/nodes').send({ expect(mockLoadNodesAndCredentials.updateNpmModule).toHaveBeenCalledTimes(1);
name: utils.installedPackagePayload().packageName,
});
expect(updateSpy).toHaveBeenCalledTimes(1);
}); });
}); });

View file

@ -14,8 +14,6 @@ import { sqliteMigrations } from '@db/migrations/sqlite';
import { hashPassword } from '@/UserManagement/UserManagementHelper'; import { hashPassword } from '@/UserManagement/UserManagementHelper';
import { AuthIdentity } from '@db/entities/AuthIdentity'; import { AuthIdentity } from '@db/entities/AuthIdentity';
import type { ExecutionEntity } from '@db/entities/ExecutionEntity'; 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 { Role } from '@db/entities/Role';
import type { TagEntity } from '@db/entities/TagEntity'; import type { TagEntity } from '@db/entities/TagEntity';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
@ -23,13 +21,7 @@ import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { ICredentialsDb } from '@/Interfaces'; import type { ICredentialsDb } from '@/Interfaces';
import { DB_INITIALIZATION_TIMEOUT } from './constants'; import { DB_INITIALIZATION_TIMEOUT } from './constants';
import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random'; import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random';
import type { import type { CollectionName, CredentialPayload, PostgresSchemaSection } from './types';
CollectionName,
CredentialPayload,
InstalledNodePayload,
InstalledPackagePayload,
PostgresSchemaSection,
} from './types';
import type { ExecutionData } from '@db/entities/ExecutionData'; import type { ExecutionData } from '@db/entities/ExecutionData';
import { generateNanoId } from '@db/utils/generators'; import { generateNanoId } from '@db/utils/generators';
import { RoleService } from '@/services/role.service'; import { RoleService } from '@/services/role.service';
@ -292,31 +284,6 @@ export async function createManyUsers(
return Db.collections.User.save(users); return Db.collections.User.save(users);
} }
// --------------------------------------
// Installed nodes and packages creation
// --------------------------------------
export async function saveInstalledPackage(
installedPackagePayload: InstalledPackagePayload,
): Promise<InstalledPackages> {
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<InstalledNodes> {
const newInstalledNode = new InstalledNodes();
Object.assign(newInstalledNode, installedNodePayload);
return Db.collections.InstalledNodes.save(newInstalledNode);
}
export async function addApiKey(user: User): Promise<User> { export async function addApiKey(user: User): Promise<User> {
user.apiKey = randomApiKey(); user.apiKey = randomApiKey();
return Db.collections.User.save(user); return Db.collections.User.save(user);

View file

@ -59,15 +59,3 @@ export type SaveCredentialFunction = (
export type PostgresSchemaSection = { export type PostgresSchemaSection = {
[K in 'host' | 'port' | 'schema' | 'user' | 'password']: { env: string }; [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;
};

View file

@ -3,27 +3,44 @@ import { InstalledPackages } from '@db/entities/InstalledPackages';
import { randomName } from '../random'; import { randomName } from '../random';
import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '../constants'; 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 { export const mockPackageName = () => NODE_PACKAGE_PREFIX + randomName();
return {
packageName: NODE_PACKAGE_PREFIX + randomName(), export const mockPackage = () =>
Container.get(InstalledPackagesRepository).create({
packageName: mockPackageName(),
installedVersion: COMMUNITY_PACKAGE_VERSION.CURRENT, installedVersion: COMMUNITY_PACKAGE_VERSION.CURRENT,
}; installedNodes: [],
} });
export function installedNodePayload(packageName: string): InstalledNodePayload { export const mockNode = (packageName: string) => {
const nodeName = randomName(); const nodeName = randomName();
return {
return Container.get(InstalledNodesRepository).create({
name: nodeName, name: nodeName,
type: nodeName, type: nodeName,
latestVersion: COMMUNITY_NODE_VERSION.CURRENT, latestVersion: COMMUNITY_NODE_VERSION.CURRENT.toString(),
package: packageName, package: { packageName },
}; });
} };
export const emptyPackage = async () => { export const emptyPackage = async () => {
const installedPackage = new InstalledPackages(); const installedPackage = new InstalledPackages();
installedPackage.installedNodes = []; installedPackage.installedNodes = [];
return installedPackage; 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];
}

View file

@ -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];
}

View file

@ -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<Parameters<typeof exec>[1]>;
type ExecCallback = NonNullable<Parameters<typeof exec>[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<string>('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<string>('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);
});
});
});