mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
refactor: Move community package logic to service (no-changelog) (#6973)
This commit is contained in:
parent
2432dcc661
commit
51093f649d
|
@ -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;
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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();
|
||||||
|
|
|
@ -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) =>
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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!;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
306
packages/cli/src/services/communityPackage.service.ts
Normal file
306
packages/cli/src/services/communityPackage.service.ts
Normal 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(' '),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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']);
|
||||||
|
|
||||||
|
|
|
@ -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(),
|
const testServer = setupTestServer({ endpointGroups: ['nodes'] });
|
||||||
hasPackageLoaded: jest.fn(),
|
|
||||||
isNpmError: jest.fn(),
|
const commonUpdatesProps = {
|
||||||
removePackageFromMissingList: jest.fn(),
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
installedVersion: COMMUNITY_PACKAGE_VERSION.CURRENT,
|
||||||
|
updateAvailable: COMMUNITY_PACKAGE_VERSION.UPDATED,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@/CommunityNodes/packageModel', () => {
|
const parsedNpmPackageName = {
|
||||||
return {
|
packageName: 'test',
|
||||||
...jest.requireActual('@/CommunityNodes/packageModel'),
|
rawString: 'test',
|
||||||
isPackageInstalled: jest.fn(),
|
|
||||||
findInstalledPackage: jest.fn(),
|
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
|
||||||
const mockedEmptyPackage = mocked(utils.emptyPackage);
|
let authAgent: SuperAgentTest;
|
||||||
|
|
||||||
const testServer = utils.setupTestServer({ endpointGroups: ['nodes'] });
|
|
||||||
|
|
||||||
let ownerShell: User;
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
|
||||||
};
|
|
||||||
|
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
|
@ -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];
|
|
||||||
}
|
|
357
packages/cli/test/unit/services/communityPackage.service.test.ts
Normal file
357
packages/cli/test/unit/services/communityPackage.service.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue