Files
n8n/packages/cli/src/modules/community-packages/__tests__/community-packages.service.test.ts
T

1797 lines
65 KiB
TypeScript

import type { Logger } from '@n8n/backend-common';
import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import { mockInstance, randomName } from '@n8n/backend-test-utils';
import { LICENSE_FEATURES } from '@n8n/constants';
import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
import {
N8N_NODES_API_VERSION,
parseNodesApiLevel,
type PublicInstalledPackage,
} from 'n8n-workflow';
import { execFile } from 'node:child_process';
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import path, { join } from 'node:path';
import { mock } from 'vitest-mock-extended';
import { NPM_PACKAGE_STATUS_GOOD } from '@/constants';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import type { License } from '@/license';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '@test-integration/constants';
import { mockPackageName, mockPackagePair } from '@test-integration/utils';
import { getCommunityNodeTypes } from '../community-node-types-utils';
import type { CommunityPackagesConfig } from '../community-packages.config';
import { CommunityPackagesService } from '../community-packages.service';
import type { CommunityPackages } from '../community-packages.types';
import { InstalledNodes } from '../installed-nodes.entity';
import { InstalledNodesRepository } from '../installed-nodes.repository';
import { InstalledPackages } from '../installed-packages.entity';
import { InstalledPackagesRepository } from '../installed-packages.repository';
import { executeNpmCommand } from '../npm-utils';
vi.mock('node:fs/promises');
// Use a plain `execFile` mock (no `[util.promisify.custom]` symbol). Vitest's
// automock preserves that symbol from the real module, which makes the source's
// module-level `promisify(execFile)` bypass the mock and call the real binary.
vi.mock('node:child_process', () => ({ execFile: vi.fn() }));
vi.mock('../community-node-types-utils', () => ({
getCommunityNodeTypes: vi.fn().mockResolvedValue([]),
}));
vi.mock('../npm-utils', async () => ({
...(await vi.importActual<typeof import('../npm-utils')>('../npm-utils')),
executeNpmCommand: vi.fn(),
executeNpmRequest: vi.fn().mockResolvedValue({}),
checkIfVersionExistsOrThrow: vi.fn().mockResolvedValue(true),
verifyIntegrity: vi.fn().mockResolvedValue(undefined),
}));
type ExecFileCallback = NonNullable<Parameters<typeof execFile>[3]>;
const execMock: typeof execFile = ((...args) => {
const currentCallback = args[args.length - 1] as ExecFileCallback;
currentCallback(null, 'Done', '');
}) as typeof execFile;
vi.mocked(execFile).mockImplementation(execMock);
const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
const oneMinorAboveSupported = `${supportedMajor}.${supportedMinor + 1}`;
describe('CommunityPackagesService', () => {
const license = mock<License>();
const configDefaults = {
enabled: true,
preventLoading: false,
reinstallMissing: false,
registry: 'some.random.host',
unverifiedEnabled: true,
authToken: '',
aiNodeSdkVersion: 1,
nodesApiVersion: N8N_NODES_API_VERSION,
};
const config = mock<CommunityPackagesConfig>({ ...configDefaults });
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>();
const installedNodesRepository = mockInstance(InstalledNodesRepository);
const installedPackageRepository = mockInstance(InstalledPackagesRepository);
const nodesDownloadDir = path.join('tmp', 'n8n-vi-global-downloads');
const instanceSettings = mock<InstanceSettings>({ nodesDownloadDir });
const logger = mock<Logger>();
const publisher = mock<Publisher>();
const request = vi.fn();
const requests = vi.fn().mockReturnValue(mock<HttpRequestClient>({ request }));
const outboundHttp = mock<OutboundHttp>({ requests });
const communityPackagesService = new CommunityPackagesService(
instanceSettings,
logger,
installedPackageRepository,
loadNodesAndCredentials,
publisher,
license,
config,
outboundHttp,
);
beforeEach(() => {
vi.resetAllMocks();
Object.assign(config, configDefaults);
loadNodesAndCredentials.postProcessLoaders.mockResolvedValue(undefined);
publisher.publishCommand.mockResolvedValue(undefined);
const nodeName = randomName();
installedNodesRepository.create.mockImplementation(() => {
return Object.assign(new InstalledNodes(), {
name: nodeName,
type: nodeName,
latestVersion: COMMUNITY_NODE_VERSION.CURRENT,
});
});
installedPackageRepository.create.mockImplementation(() => {
return Object.assign(new InstalledPackages(), {
packageName: mockPackageName(),
installedVersion: COMMUNITY_PACKAGE_VERSION.CURRENT,
});
});
});
describe('parseNpmPackageName()', () => {
test('should fail with empty package name', () => {
expect(() => communityPackagesService.parseNpmPackageName('')).toThrowError();
});
test('should fail with invalid package prefix name', () => {
expect(() =>
communityPackagesService.parseNpmPackageName('INVALID_PREFIX@123'),
).toThrowError();
});
test.each(['1.a.b', '1invalid', '-starts-with-dash'])(
'should fail with invalid version',
(version) => {
expect(() =>
communityPackagesService.parseNpmPackageName(`n8n-nodes-test@${version}`),
).toThrow(`Invalid version: ${version}`);
},
);
test.each([
['n8n-nodes-test', 'n8n-nodes-test', undefined, undefined],
['[email protected]', 'n8n-nodes-test', undefined, '1.2.3'],
['@scope/n8n-nodes-test', '@scope/n8n-nodes-test', '@scope', undefined],
['@scope/[email protected]', '@scope/n8n-nodes-test', '@scope', '1.2.3'],
])('should parse "%s"', (rawString, packageName, scope, version) => {
expect(communityPackagesService.parseNpmPackageName(rawString)).toEqual({
rawString,
packageName,
scope,
version,
});
});
test.each(['n8n-nodes-base', '@n8n/n8n-nodes-langchain'])(
'should reject reserved package name %s',
(name) => {
expect(() => communityPackagesService.parseNpmPackageName(name)).toThrow(/reserved/);
},
);
test.each(['[email protected]', '@n8n/[email protected]'])(
'should reject reserved package name with version specifier %s',
(name) => {
expect(() => communityPackagesService.parseNpmPackageName(name)).toThrow(/reserved/);
},
);
test('should accept a package name that merely starts with a reserved name', () => {
const parsed = communityPackagesService.parseNpmPackageName('n8n-nodes-base-extended');
expect(parsed.packageName).toBe('n8n-nodes-base-extended');
});
test.each(['beta', 'next', 'latest', 'canary', 'rc-1'])(
'should accept npm dist-tag as version',
(tag) => {
const parsed = communityPackagesService.parseNpmPackageName(`n8n-nodes-test@${tag}`);
expect(parsed.version).toBe(tag);
},
);
test.each(['../n8n-nodes-test', 'n8n-nodes-test/n8n-nodes-test'])(
'should fail with an invalid scope segment',
(name) => {
expect(() => communityPackagesService.parseNpmPackageName(name)).toThrowError();
},
);
test.each([
'@scope/n8n-nodes-test/..',
'@scope/n8n-nodes-test/../..',
'n8n-nodes-test/..',
'n8n-nodes-test/../../node_modules/n8n-nodes-other',
])('should fail when the name carries extra path segments (%s)', (name) => {
expect(() => communityPackagesService.parseNpmPackageName(name)).toThrowError();
});
test('should parse valid package name', () => {
const name = mockPackageName();
const parsed = communityPackagesService.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 = communityPackagesService.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 = communityPackagesService.parseNpmPackageName(fullPackageName);
expect(parsed.rawString).toBe(fullPackageName);
expect(parsed.packageName).toBe(`${scope}/${name}`);
expect(parsed.scope).toBe(scope);
expect(parsed.version).toBe(version);
});
});
describe('resolvePackageDirectory()', () => {
// Called directly: every caller reaching it in production has already been through
// `parseNpmPackageName`, so only a direct call covers the guard on its own.
const resolvePackageDirectory = (packageName: string) =>
communityPackagesService['resolvePackageDirectory'](packageName);
test.each(['@scope/n8n-nodes-test/..', 'n8n-nodes-test/..', '../../etc', '..', '.', ''])(
'should reject the name "%s"',
(packageName) => {
expect(() => resolvePackageDirectory(packageName)).toThrowError('Invalid package name');
},
);
test.each(['n8n-nodes-test', '@scope/n8n-nodes-test'])(
'should resolve the directory for "%s"',
(packageName) => {
expect(resolvePackageDirectory(packageName)).toBe(
`${nodesDownloadDir}/node_modules/${packageName}`,
);
},
);
});
describe('crossInformationPackage()', () => {
test('should return same list if availableUpdates is undefined', () => {
const fakePkgs = mockPackagePair();
const crossedPkgs = communityPackagesService.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[] =
communityPackagesService.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[] =
communityPackagesService.matchPackagesWithUpdates([pkgA, pkgB], updates);
expect(crossedPkgA.updateAvailable).toBeUndefined();
expect(crossedPkgB.updateAvailable).toBe('0.3.0');
});
});
/** `mockPackagePair()`'s node-repository mock ignores its input and returns one shared
* `type` per test, so tests that need distinct/specific node types build fixtures directly. */
const installedPackageWithNodeTypes = (nodeTypes: string[]) =>
Object.assign(new InstalledPackages(), {
packageName: mockPackageName(),
installedVersion: COMMUNITY_PACKAGE_VERSION.CURRENT,
installedNodes: nodeTypes.map((type) =>
Object.assign(new InstalledNodes(), { name: type, type, latestVersion: 1 }),
),
});
describe('withLoadStatus()', () => {
test('should not flag packages whose nodes are all known', () => {
const pkgA = installedPackageWithNodeTypes(['node-a']);
const pkgB = installedPackageWithNodeTypes(['node-b1', 'node-b2']);
loadNodesAndCredentials.isKnownNode.mockReturnValue(true);
const [matchedPkgA, matchedPkgB] = communityPackagesService.withLoadStatus([pkgA, pkgB]);
expect(matchedPkgA.failedLoading).toBe(false);
expect(matchedPkgB.failedLoading).toBe(false);
});
test('should flag a package none of whose nodes are known', () => {
const pkgA = installedPackageWithNodeTypes(['node-a']);
const pkgB = installedPackageWithNodeTypes(['node-b']);
loadNodesAndCredentials.isKnownNode.mockImplementation((type) => type === 'node-b');
const [matchedPkgA, matchedPkgB] = communityPackagesService.withLoadStatus([pkgA, pkgB]);
expect(matchedPkgA.failedLoading).toBe(true);
expect(matchedPkgB.failedLoading).toBe(false);
});
test('should not flag a package that still has one stale node row from a dropped node type', () => {
// Simulates a repair-reinstall through `saveInstalledPackageWithNodes`, which (unlike
// `replaceInstalledPackageWithNodes`) doesn't delete node rows from a previous version,
// so a package that dropped a node type keeps a row that will never resolve again.
const pkg = installedPackageWithNodeTypes(['old-dropped-node', 'current-node']);
loadNodesAndCredentials.isKnownNode.mockImplementation((type) => type === 'current-node');
const [matched] = communityPackagesService.withLoadStatus([pkg]);
expect(loadNodesAndCredentials.isKnownNode).toHaveBeenCalledWith('old-dropped-node');
expect(matched.failedLoading).toBe(false);
});
test('reflects the current loader state rather than a snapshot, with no install call in between', () => {
const pkg = installedPackageWithNodeTypes(['node-a']);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
expect(communityPackagesService.withLoadStatus([pkg])[0].failedLoading).toBe(true);
loadNodesAndCredentials.isKnownNode.mockReturnValue(true);
expect(communityPackagesService.withLoadStatus([pkg])[0].failedLoading).toBe(false);
});
});
describe('checkNpmPackageStatus()', () => {
test('should POST the package name to the n8n backend', async () => {
const packageName = mockPackageName();
await communityPackagesService.checkNpmPackageStatus(packageName);
expect(request).toHaveBeenCalledWith({
url: 'https://api.n8n.io/api/package',
method: 'POST',
body: { name: packageName },
json: true,
});
});
test('should not fail if request fails', async () => {
request.mockImplementation(() => {
throw new Error('Something went wrong');
});
const result = await communityPackagesService.checkNpmPackageStatus(mockPackageName());
expect(result.status).toBe(NPM_PACKAGE_STATUS_GOOD);
});
test('should warn if package is banned', async () => {
request.mockResolvedValue({ status: 'Banned', reason: 'Not good' });
const result = (await communityPackagesService.checkNpmPackageStatus(
mockPackageName(),
)) as CommunityPackages.PackageStatusCheck;
expect(result.status).toBe('Banned');
expect(result.reason).toBe('Not good');
});
});
describe('isPackageLoaded()', () => {
test('should return true for a package with no installed nodes', () => {
const installedPackage = installedPackageWithNodeTypes([]);
expect(communityPackagesService.isPackageLoaded(installedPackage)).toBe(true);
});
test('should return true when at least one installed node is known', () => {
const installedPackage = installedPackageWithNodeTypes(['node-a', 'node-b']);
loadNodesAndCredentials.isKnownNode.mockImplementation((type) => type === 'node-b');
expect(communityPackagesService.isPackageLoaded(installedPackage)).toBe(true);
});
test('should return false when no installed node is known', () => {
const installedPackage = installedPackageWithNodeTypes(['node-a']);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
expect(communityPackagesService.isPackageLoaded(installedPackage)).toBe(false);
});
});
describe('updatePackage', () => {
const PACKAGE_NAME = 'n8n-nodes-test';
const installedPackageForUpdateTest = mock<InstalledPackages>({
packageName: PACKAGE_NAME,
installedVersion: COMMUNITY_PACKAGE_VERSION.CURRENT,
});
const packageAfterUpdate = mock<InstalledPackages>({
packageName: PACKAGE_NAME,
installedVersion: COMMUNITY_PACKAGE_VERSION.UPDATED,
});
const packageDirectoryLoader = mock<PackageDirectoryLoader>({
loadedNodes: [{ name: 'a-node-from-the-loader', version: 1 }],
});
const testBlockDownloadDir = instanceSettings.nodesDownloadDir;
const testBlockPackageDir = `${testBlockDownloadDir}/node_modules/${PACKAGE_NAME}`;
const testBlockTarballName = `${PACKAGE_NAME}-latest.tgz`;
const testBlockRegistry = config.registry;
const testBlockNpmInstallArgs = [
'--audit=false',
'--fund=false',
'--bin-links=false',
'--install-strategy=shallow',
'--ignore-scripts=true',
'--package-lock=false',
].join(' ');
const execMockForThisBlock = ((...args: Parameters<typeof execFile>) => {
const command = args[0];
const cmdArgs = args[1];
const actualCallback = args[args.length - 1] as ExecFileCallback;
if (command === 'npm' && cmdArgs?.[0] === 'pack') {
actualCallback(null, testBlockTarballName, '');
} else {
actualCallback(null, 'Done', '');
}
}) as typeof execFile;
beforeEach(() => {
vi.clearAllMocks();
// A directory is there for the pre-download backup, and again after a rollback
// puts it back, which is what decides whether the rollback reloads it.
vi.mocked(access).mockResolvedValue(undefined);
vi.mocked(execFile).mockImplementation(execMockForThisBlock);
vi.mocked(executeNpmCommand).mockImplementation(async (args: string[]) => {
if (args[0] === 'pack') {
return testBlockTarballName;
}
return 'Done';
});
vi.mocked(readFile).mockResolvedValue(
JSON.stringify({
name: PACKAGE_NAME,
version: '1.0.0', // Mocked version from package.json inside tarball
dependencies: { 'some-actual-dep': '1.2.3' },
devDependencies: { 'a-dev-dep': '1.0.0' },
peerDependencies: { 'a-peer-dep': '2.0.0' },
optionalDependencies: { 'an-optional-dep': '3.0.0' },
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION },
}),
);
vi.mocked(writeFile).mockResolvedValue(undefined);
loadNodesAndCredentials.loadPackage.mockResolvedValue(packageDirectoryLoader);
loadNodesAndCredentials.unloadPackage.mockResolvedValue(undefined);
loadNodesAndCredentials.postProcessLoaders.mockResolvedValue(undefined);
installedPackageRepository.remove.mockResolvedValue(undefined as any);
installedPackageRepository.saveInstalledPackageWithNodes.mockResolvedValue(
installedPackageForUpdateTest,
);
installedPackageRepository.replaceInstalledPackageWithNodes.mockResolvedValue(
packageAfterUpdate,
);
publisher.publishCommand.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
test('should restore the previous package directory when loading the updated package fails', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
vi.spyOn(Date, 'now').mockReturnValue(1_717_171_717_171);
const backupDirectory = `${testBlockPackageDir}.backup-1717171717171`;
loadNodesAndCredentials.loadPackage.mockRejectedValueOnce(new Error('broken package'));
await expect(
communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
),
).rejects.toThrow('The specified package could not be loaded');
expect(rename).toHaveBeenNthCalledWith(1, testBlockPackageDir, backupDirectory);
expect(rename).toHaveBeenNthCalledWith(2, backupDirectory, testBlockPackageDir);
});
test('should restore the previous package.json dependency version when an update fails', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
loadNodesAndCredentials.loadPackage.mockRejectedValueOnce(new Error('broken package'));
vi.mocked(readFile)
// The ledger as it stands before the update, which is what the rollback restores.
.mockResolvedValueOnce(
JSON.stringify({
name: 'installed-nodes',
private: true,
dependencies: { [PACKAGE_NAME]: COMMUNITY_PACKAGE_VERSION.CURRENT },
}),
)
// The extracted package.json, which the download strips and writes back.
.mockResolvedValueOnce(
JSON.stringify({
name: PACKAGE_NAME,
version: '2.0.0',
dependencies: { 'some-actual-dep': '1.2.3' },
devDependencies: {},
peerDependencies: {},
optionalDependencies: {},
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION },
}),
)
.mockResolvedValueOnce(
JSON.stringify({
name: 'installed-nodes',
private: true,
dependencies: { [PACKAGE_NAME]: '2.0.0' },
}),
)
// The compatibility guard reads the same extracted package.json, now stripped.
.mockResolvedValueOnce(
JSON.stringify({
name: PACKAGE_NAME,
version: '2.0.0',
dependencies: { 'some-actual-dep': '1.2.3' },
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION },
}),
)
.mockResolvedValueOnce(
JSON.stringify({
name: 'installed-nodes',
private: true,
dependencies: { [PACKAGE_NAME]: '2.0.0' },
}),
);
await expect(
communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
),
).rejects.toThrow('The specified package could not be loaded');
expect(writeFile).toHaveBeenNthCalledWith(
3,
path.join(nodesDownloadDir, 'package.json'),
JSON.stringify(
{
name: 'installed-nodes',
private: true,
dependencies: { [PACKAGE_NAME]: COMMUNITY_PACKAGE_VERSION.CURRENT },
},
null,
2,
),
'utf-8',
);
});
test('should reload the restored package when an update fails', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
loadNodesAndCredentials.loadPackage.mockRejectedValueOnce(new Error('broken package'));
await expect(
communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
),
).rejects.toThrow('The specified package could not be loaded');
// Unloaded twice: once before the failed load, once to drop it during the rollback.
expect(loadNodesAndCredentials.unloadPackage).toHaveBeenCalledTimes(2);
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledTimes(2);
expect(loadNodesAndCredentials.loadPackage).toHaveBeenNthCalledWith(2, PACKAGE_NAME);
expect(loadNodesAndCredentials.postProcessLoaders).toHaveBeenCalledTimes(1);
expect(loadNodesAndCredentials.releaseTypes).toHaveBeenCalledTimes(1);
});
test('should restore the previous package without reloading when the download fails during an update', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
vi.spyOn(Date, 'now').mockReturnValue(1_717_171_717_171);
const backupDirectory = `${testBlockPackageDir}.backup-1717171717171`;
vi.mocked(executeNpmCommand).mockRejectedValueOnce(new Error('download failed'));
await expect(
communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
),
).rejects.toThrow('download failed');
expect(rename).toHaveBeenNthCalledWith(1, testBlockPackageDir, backupDirectory);
expect(rename).toHaveBeenNthCalledWith(2, backupDirectory, testBlockPackageDir);
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
expect(loadNodesAndCredentials.unloadPackage).not.toHaveBeenCalled();
});
test('should restore the previous package when the updated package contains no loadable nodes', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
vi.spyOn(Date, 'now').mockReturnValue(1_717_171_717_171);
const backupDirectory = `${testBlockPackageDir}.backup-1717171717171`;
loadNodesAndCredentials.loadPackage.mockResolvedValueOnce(
mock<PackageDirectoryLoader>({ loadedNodes: [] }),
);
await expect(
communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
),
).rejects.toThrow();
expect(rename).toHaveBeenNthCalledWith(2, backupDirectory, testBlockPackageDir);
expect(installedPackageRepository.replaceInstalledPackageWithNodes).not.toHaveBeenCalled();
});
describe('node API version guard', () => {
const updateToIncompatible = async (n8nNodesApiVersion: unknown) => {
vi.mocked(readFile).mockResolvedValue(
JSON.stringify({
name: PACKAGE_NAME,
version: '2.0.0',
dependencies: { 'some-actual-dep': '1.2.3' },
n8n: { n8nNodesApiVersion },
}),
);
return await communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
);
};
test('should reject the update when the package requires a newer node API version', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
await expect(updateToIncompatible(oneMinorAboveSupported)).rejects.toThrow(
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
);
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
expect(installedPackageRepository.replaceInstalledPackageWithNodes).not.toHaveBeenCalled();
expect(publisher.publishCommand).not.toHaveBeenCalled();
});
test('should reject the update when the declared node API version is malformed', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
await expect(updateToIncompatible(3.1)).rejects.toThrow('invalid n8n node API version');
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
expect(installedPackageRepository.replaceInstalledPackageWithNodes).not.toHaveBeenCalled();
});
test('should update to a package that declares the supported node API version', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
await expect(updateToIncompatible(N8N_NODES_API_VERSION)).resolves.toBe(packageAfterUpdate);
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
expect(installedPackageRepository.replaceInstalledPackageWithNodes).toHaveBeenCalled();
});
test('should update to a package that declares the supported level with an explicit minor', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
await expect(updateToIncompatible(`${supportedMajor}.${supportedMinor}`)).resolves.toBe(
packageAfterUpdate,
);
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
});
});
test('should remove the package.json dependency when a fresh install fails', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
// No pre-existing directory here, unlike the shared beforeEach's update scenario.
vi.mocked(access).mockReset().mockRejectedValue(new Error('ENOENT'));
loadNodesAndCredentials.loadPackage.mockRejectedValueOnce(new Error('broken package'));
vi.mocked(readFile)
// Nothing on disk and nothing in the ledger, so there is no entry to restore.
.mockResolvedValueOnce(
JSON.stringify({ name: 'installed-nodes', private: true, dependencies: {} }),
)
// The extracted package.json, which the download strips and writes back.
.mockResolvedValueOnce(
JSON.stringify({
name: PACKAGE_NAME,
version: '1.0.0',
dependencies: {},
devDependencies: {},
peerDependencies: {},
optionalDependencies: {},
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION },
}),
)
.mockResolvedValueOnce(
JSON.stringify({
name: 'installed-nodes',
private: true,
dependencies: { [PACKAGE_NAME]: '1.0.0' },
}),
)
// The compatibility guard reads the same extracted package.json, now stripped.
.mockResolvedValueOnce(
JSON.stringify({
name: PACKAGE_NAME,
version: '1.0.0',
dependencies: {},
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION },
}),
)
.mockResolvedValueOnce(
JSON.stringify({
name: 'installed-nodes',
private: true,
dependencies: { [PACKAGE_NAME]: '1.0.0' },
}),
);
await expect(communityPackagesService.installPackage(PACKAGE_NAME)).rejects.toThrow(
'The specified package could not be loaded',
);
// Nothing to back up, so cleanup only deletes — no rename.
expect(rename).not.toHaveBeenCalled();
expect(writeFile).toHaveBeenNthCalledWith(
3,
path.join(nodesDownloadDir, 'package.json'),
JSON.stringify({ name: 'installed-nodes', private: true, dependencies: {} }, null, 2),
'utf-8',
);
});
test('should not attempt a reload when the directory restore left nothing behind', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
vi.spyOn(Date, 'now').mockReturnValue(1_717_171_717_171);
const backupDirectory = `${testBlockPackageDir}.backup-1717171717171`;
// A directory to back up, but none once the restore below fails to put it back.
vi.mocked(access)
.mockReset()
.mockResolvedValueOnce(undefined)
.mockRejectedValue(new Error('ENOENT'));
vi.mocked(rename).mockImplementation(async (from) => {
if (from === backupDirectory) throw new Error('EPERM');
});
installedPackageRepository.replaceInstalledPackageWithNodes.mockRejectedValueOnce(
new Error('DB unreachable'),
);
await expect(
communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
),
).rejects.toThrow('Failed to save installed package');
// Loading a directory the restore failed to put back can only fail, and its
// warning would point at the reload instead of the restore that actually broke.
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledTimes(1);
expect(logger.warn).not.toHaveBeenCalledWith(
'Failed to reload community package after failed installation',
expect.anything(),
);
// The registry still has to be rebuilt: the unload already happened.
expect(loadNodesAndCredentials.postProcessLoaders).toHaveBeenCalledTimes(1);
});
test('should unload the package when a fresh install fails to save to the database', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
// No pre-existing directory here, unlike the shared beforeEach's update scenario.
vi.mocked(access).mockReset().mockRejectedValue(new Error('ENOENT'));
installedPackageRepository.saveInstalledPackageWithNodes.mockRejectedValueOnce(
new Error('DB unreachable'),
);
await expect(communityPackagesService.installPackage(PACKAGE_NAME)).rejects.toThrow(
'Failed to save installed package',
);
// The load succeeded, so the loader has to go: the install is reported as failed,
// left no database record, and its directory is gone.
expect(loadNodesAndCredentials.unloadPackage).toHaveBeenCalledTimes(2);
expect(loadNodesAndCredentials.unloadPackage).toHaveBeenLastCalledWith(PACKAGE_NAME);
// Nothing was backed up, so there is no previous version to load again.
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledTimes(1);
expect(loadNodesAndCredentials.postProcessLoaders).toHaveBeenCalledTimes(1);
});
test('should unload the package when a fresh install contains no loadable nodes', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
vi.mocked(access).mockReset().mockRejectedValue(new Error('ENOENT'));
loadNodesAndCredentials.loadPackage.mockResolvedValueOnce(
mock<PackageDirectoryLoader>({ loadedNodes: [] }),
);
await expect(communityPackagesService.installPackage(PACKAGE_NAME)).rejects.toThrow();
expect(loadNodesAndCredentials.unloadPackage).toHaveBeenCalledTimes(2);
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledTimes(1);
});
test('should still succeed when removing the backup directory fails after the update', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
vi.spyOn(Date, 'now').mockReturnValue(1_717_171_717_171);
const backupDirectory = `${testBlockPackageDir}.backup-1717171717171`;
// Fail only the backup cleanup, which runs after the DB update has committed
vi.mocked(rm).mockImplementation(async (target) => {
if (target === backupDirectory) throw new Error('cleanup failed');
return undefined;
});
await expect(
communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
),
).resolves.toBe(packageAfterUpdate);
expect(logger.warn).toHaveBeenCalledWith(
'Failed to remove community package backup directory',
expect.objectContaining({ backupDirectory }),
);
});
test('should not roll back when a post-save step fails after the database is updated', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
// Fails only after the DB swap has already committed the new version
loadNodesAndCredentials.postProcessLoaders.mockRejectedValueOnce(
new Error('post-process failed'),
);
await expect(
communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
),
).rejects.toThrow('post-process failed');
expect(installedPackageRepository.replaceInstalledPackageWithNodes).toHaveBeenCalled();
// No restore: the new version is already authoritative in the DB
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledTimes(1);
});
test('should call `exec` with the correct sequence of commands, handle file ops, and interact with services', async () => {
// ARRANGE
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
vi.spyOn(Date, 'now').mockReturnValue(1_717_171_717_171);
const backupDirectory = `${testBlockPackageDir}.backup-1717171717171`;
// ACT
await communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
);
// ASSERT:
// Only the pre-download backup rename — success needs no restore.
expect(rename).toHaveBeenCalledTimes(1);
expect(rename).toHaveBeenCalledWith(testBlockPackageDir, backupDirectory);
expect(rm).toHaveBeenCalledTimes(2);
expect(rm).toHaveBeenNthCalledWith(
1,
path.join(nodesDownloadDir, 'n8n-nodes-test-latest.tgz'),
);
expect(rm).toHaveBeenNthCalledWith(2, backupDirectory, {
recursive: true,
force: true,
maxRetries: 3,
});
// Check executeNpmCommand was called for npm commands
expect(executeNpmCommand).toHaveBeenCalledTimes(2);
expect(executeNpmCommand).toHaveBeenNthCalledWith(
1,
['pack', `${PACKAGE_NAME}@latest`, '--quiet'],
{ cwd: testBlockDownloadDir, registry: testBlockRegistry, authToken: undefined },
);
expect(executeNpmCommand).toHaveBeenNthCalledWith(
2,
['install', ...testBlockNpmInstallArgs.split(' ')],
{ cwd: testBlockPackageDir, registry: testBlockRegistry, authToken: undefined },
);
// Check execFile was called only for tar command
expect(execFile).toHaveBeenCalledTimes(1);
expect(execFile).toHaveBeenCalledWith(
'tar',
['-xzf', testBlockTarballName, '-C', testBlockPackageDir, '--strip-components=1'],
{ cwd: testBlockDownloadDir },
expect.any(Function),
);
expect(mkdir).toHaveBeenCalledWith(testBlockPackageDir, { recursive: true });
expect(readFile).toHaveBeenCalledWith(`${testBlockPackageDir}/package.json`, 'utf-8');
expect(writeFile).toHaveBeenCalledWith(
`${testBlockPackageDir}/package.json`,
JSON.stringify(
{
name: PACKAGE_NAME,
version: '1.0.0',
dependencies: { 'some-actual-dep': '1.2.3' },
// The dependency-stripping rewrite must keep the `n8n` section: the
// compatibility guard reads it from the rewritten file.
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION },
},
null,
2,
),
'utf-8',
);
expect(loadNodesAndCredentials.unloadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
expect(loadNodesAndCredentials.postProcessLoaders).toHaveBeenCalledTimes(1);
expect(installedPackageRepository.replaceInstalledPackageWithNodes).toHaveBeenCalledWith(
installedPackageForUpdateTest,
packageDirectoryLoader,
);
expect(installedPackageRepository.remove).not.toHaveBeenCalled();
expect(installedPackageRepository.saveInstalledPackageWithNodes).not.toHaveBeenCalled();
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'community-package-update',
// The resolved version that was just persisted, not the requested `latest` specifier.
payload: {
packageName: PACKAGE_NAME,
packageVersion: COMMUNITY_PACKAGE_VERSION.UPDATED,
checksum: undefined,
},
});
});
test('should still succeed when publishing the update event fails', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
publisher.publishCommand.mockRejectedValue(new Error('Redis unreachable'));
await expect(
communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
),
).resolves.toBe(packageAfterUpdate);
await Promise.resolve();
await Promise.resolve();
expect(logger.warn).toHaveBeenCalledWith(
'Failed to publish community package install/update event',
expect.objectContaining({ packageName: PACKAGE_NAME }),
);
});
test('should publish the resolved version and the checksum when one is provided', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
await communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
'latest',
'sha512-abc123',
);
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'community-package-update',
payload: {
packageName: PACKAGE_NAME,
packageVersion: COMMUNITY_PACKAGE_VERSION.UPDATED,
checksum: 'sha512-abc123',
},
});
});
test('should not attempt to delete the tarball when npm pack prints no filename', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
vi.spyOn(Date, 'now').mockReturnValue(1_717_171_717_171);
const backupDirectory = `${testBlockPackageDir}.backup-1717171717171`;
vi.mocked(executeNpmCommand).mockImplementation(async (args: string[]) => {
if (args[0] === 'pack') return '';
return 'Done';
});
await communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
);
expect(rm).not.toHaveBeenCalledWith(testBlockDownloadDir);
expect(rm).toHaveBeenCalledTimes(1);
expect(rm).toHaveBeenCalledWith(backupDirectory, {
recursive: true,
force: true,
maxRetries: 3,
});
});
test('should throw when not licensed for custom registry if custom registry is different from default', async () => {
// ARRANGE
license.isCustomNpmRegistryEnabled.mockReturnValue(false);
// ACT & ASSERT
const promise = communityPackagesService.updatePackage(
installedPackageForUpdateTest.packageName,
installedPackageForUpdateTest,
);
await expect(promise).rejects.toThrow(FeatureNotLicensedError);
await expect(promise).rejects.toThrow(LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY);
});
});
describe('installPackage', () => {
test('should throw when the package status check rejects the package', async () => {
config.registry = 'https://registry.npmjs.org';
request.mockResolvedValue({ status: 'Banned', reason: 'Not good' });
await expect(communityPackagesService.installPackage('package', '0.1.0')).rejects.toThrow(
'Package "package" is not allowed',
);
});
test('should throw when installation of not vetted packages is forbidden', async () => {
config.unverifiedEnabled = false;
config.registry = 'https://registry.npmjs.org';
await expect(communityPackagesService.installPackage('package', '0.1.0')).rejects.toThrow(
'Installation of unverified community packages is forbidden!',
);
});
});
describe('removePackage', () => {
test('should remove a broken package that cannot be loaded', async () => {
const PACKAGE_NAME = 'n8n-nodes-broken';
const installedPackage = mock<InstalledPackages>({ packageName: PACKAGE_NAME });
// A broken package fails to load, but removal must not depend on loading it
loadNodesAndCredentials.loadPackage.mockRejectedValue(
new Error('The specified package could not be loaded'),
);
loadNodesAndCredentials.unloadPackage.mockResolvedValue(undefined);
loadNodesAndCredentials.postProcessLoaders.mockResolvedValue(undefined);
vi.mocked(rm).mockResolvedValue(undefined);
installedPackageRepository.remove.mockResolvedValue(undefined as never);
await expect(
communityPackagesService.removePackage(PACKAGE_NAME, installedPackage),
).resolves.toBeUndefined();
expect(rm).toHaveBeenCalledWith(`${nodesDownloadDir}/node_modules/${PACKAGE_NAME}`, {
recursive: true,
force: true,
maxRetries: 3,
});
expect(installedPackageRepository.remove).toHaveBeenCalledWith(installedPackage);
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
});
test('should log and not throw when publishing the uninstall event fails', async () => {
const PACKAGE_NAME = 'n8n-nodes-test';
const installedPackage = mock<InstalledPackages>({ packageName: PACKAGE_NAME });
loadNodesAndCredentials.unloadPackage.mockResolvedValue(undefined);
loadNodesAndCredentials.postProcessLoaders.mockResolvedValue(undefined);
vi.mocked(rm).mockResolvedValue(undefined);
installedPackageRepository.remove.mockResolvedValue(undefined as never);
publisher.publishCommand.mockRejectedValue(new Error('Redis unreachable'));
await expect(
communityPackagesService.removePackage(PACKAGE_NAME, installedPackage),
).resolves.toBeUndefined();
await Promise.resolve();
await Promise.resolve();
expect(logger.warn).toHaveBeenCalledWith(
'Failed to publish community package uninstall event',
expect.objectContaining({ packageName: PACKAGE_NAME }),
);
});
});
describe('restorePackageFiles', () => {
test('restores the package directory before updating the package.json manifest, so a crash mid-restore leaves the directory intact', async () => {
const packageName = 'n8n-nodes-test';
const backupDirectory = `${nodesDownloadDir}/node_modules/${packageName}.backup-123`;
const callOrder: string[] = [];
vi.mocked(rm).mockResolvedValue(undefined);
vi.mocked(rename).mockImplementation(async () => {
callOrder.push('rename');
});
vi.spyOn(communityPackagesService, 'updatePackageJsonDependency').mockImplementation(
async () => {
callOrder.push('updatePackageJsonDependency');
},
);
await (communityPackagesService as any).restorePackageFiles(packageName, {
backupDirectory,
previousVersion: '1.0.0',
});
expect(callOrder).toEqual(['rename', 'updatePackageJsonDependency']);
});
test('rolls the package.json dependency back even when restoring the directory fails', async () => {
const packageName = 'n8n-nodes-test';
const backupDirectory = `${nodesDownloadDir}/node_modules/${packageName}.backup-123`;
vi.mocked(rm).mockResolvedValue(undefined);
vi.mocked(rename).mockRejectedValue(new Error('EPERM'));
const updateDependency = vi
.spyOn(communityPackagesService, 'updatePackageJsonDependency')
.mockResolvedValue(undefined);
await (communityPackagesService as any).restorePackageFiles(packageName, {
backupDirectory,
previousVersion: '1.0.0',
});
// The manifest must not keep naming the version that failed to install, even
// though the directory it describes could not be restored.
expect(updateDependency).toHaveBeenCalledWith(packageName, '1.0.0');
});
test('drops the manifest entry when a fresh install fails and the directory restore fails', async () => {
const packageName = 'n8n-nodes-test';
vi.mocked(rm).mockRejectedValue(new Error('EBUSY'));
vi.mocked(readFile).mockResolvedValue(
JSON.stringify({ dependencies: { [packageName]: '1.0.0' } }),
);
await (communityPackagesService as any).restorePackageFiles(packageName, {});
expect(writeFile).toHaveBeenCalledWith(
path.join(nodesDownloadDir, 'package.json'),
JSON.stringify({ dependencies: {} }, null, 2),
'utf-8',
);
});
});
describe('restoreLoadedPackage', () => {
const packageName = 'n8n-nodes-test';
beforeEach(() => {
loadNodesAndCredentials.unloadPackage.mockResolvedValue(undefined);
loadNodesAndCredentials.loadPackage.mockResolvedValue(mock<PackageDirectoryLoader>());
loadNodesAndCredentials.postProcessLoaders.mockResolvedValue(undefined);
});
test('reloads the package when the rollback left a directory on disk', async () => {
vi.mocked(access).mockResolvedValue(undefined);
await (communityPackagesService as any).restoreLoadedPackage(packageName);
expect(loadNodesAndCredentials.unloadPackage).toHaveBeenCalledWith(packageName);
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(packageName);
});
test('unloads without reloading when the rollback left nothing on disk', async () => {
vi.mocked(access).mockRejectedValue(new Error('ENOENT'));
await (communityPackagesService as any).restoreLoadedPackage(packageName);
// The loader for the version that failed has to go even with no previous version
// to bring back, or its nodes stay usable with nothing on disk behind them.
expect(loadNodesAndCredentials.unloadPackage).toHaveBeenCalledWith(packageName);
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
expect(loadNodesAndCredentials.postProcessLoaders).toHaveBeenCalledTimes(1);
});
test('rebuilds the node type registry even when the reload fails', async () => {
vi.mocked(access).mockResolvedValue(undefined);
loadNodesAndCredentials.loadPackage.mockRejectedValueOnce(new Error('ENOENT'));
await (communityPackagesService as any).restoreLoadedPackage(packageName);
// Without this the registry keeps advertising the package's node types with no
// loader behind them, and `withLoadStatus` reports it as loaded.
expect(loadNodesAndCredentials.postProcessLoaders).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
'Failed to reload community package after failed installation',
expect.objectContaining({ packageName }),
);
});
test('warns instead of throwing when the unload fails', async () => {
loadNodesAndCredentials.unloadPackage.mockRejectedValueOnce(new Error('unload failed'));
await expect(
(communityPackagesService as any).restoreLoadedPackage(packageName),
).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
'Failed to reload community package after failed installation',
expect.objectContaining({ packageName }),
);
});
});
describe('ensurePackageJson', () => {
const packageJsonPath = join(nodesDownloadDir, 'package.json');
const packageJsonWith = (dependencies: Record<string, string>) =>
JSON.stringify({ name: 'installed-nodes', private: true, dependencies }, null, 2);
beforeEach(() => {
installedPackageRepository.find.mockResolvedValue([]);
});
test('should not recreate package.json if its content is valid', async () => {
vi.mocked(readFile).mockResolvedValue(
JSON.stringify({ name: 'installed-nodes', private: true, dependencies: {} }),
);
await communityPackagesService.ensurePackageJson();
expect(readFile).toHaveBeenCalledWith(packageJsonPath, 'utf-8');
expect(mkdir).not.toHaveBeenCalled();
expect(writeFile).not.toHaveBeenCalled();
});
test('should create package.json if it does not exist, without warning', async () => {
// A missing ledger is the normal state before the first install, so warning about
// it would flag every fresh instance's first boot as a failure.
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
await communityPackagesService.ensurePackageJson();
expect(mkdir).toHaveBeenCalledWith(nodesDownloadDir, { recursive: true });
expect(writeFile).toHaveBeenCalledWith(packageJsonPath, packageJsonWith({}), 'utf-8');
expect(logger.warn).not.toHaveBeenCalled();
});
test('should recreate package.json if its content is corrupted', async () => {
vi.mocked(readFile).mockResolvedValue('{ "name": "installed-nodes", "depende');
await communityPackagesService.ensurePackageJson();
expect(mkdir).toHaveBeenCalledWith(nodesDownloadDir, { recursive: true });
expect(writeFile).toHaveBeenCalledWith(packageJsonPath, packageJsonWith({}), 'utf-8');
});
test('should warn when the existing package.json cannot be parsed', async () => {
vi.mocked(readFile).mockResolvedValue('{ "name": "installed-nodes", "depende');
await communityPackagesService.ensurePackageJson();
expect(logger.warn).toHaveBeenCalledWith(
'Community package ledger is unusable, rebuilding it',
);
});
test('should recreate package.json if it parses but has no dependencies', async () => {
// `{}` parses fine, so it used to be accepted and then throw on the next mutation.
vi.mocked(readFile).mockResolvedValue('{}');
await communityPackagesService.ensurePackageJson();
expect(writeFile).toHaveBeenCalledWith(packageJsonPath, packageJsonWith({}), 'utf-8');
});
test('should rebuild dependencies from the database rather than emptying them', async () => {
// An empty `dependencies` leaves `npm outdated` nothing to compare, so every
// package silently loses its available-update indicator.
vi.mocked(readFile).mockResolvedValue('{ "name": "installed-nodes", "depende');
installedPackageRepository.find.mockResolvedValue([
mock<InstalledPackages>({ packageName: 'n8n-nodes-a', installedVersion: '1.2.3' }),
mock<InstalledPackages>({ packageName: 'n8n-nodes-b', installedVersion: '4.5.6' }),
]);
await communityPackagesService.ensurePackageJson();
expect(writeFile).toHaveBeenCalledWith(
packageJsonPath,
packageJsonWith({ 'n8n-nodes-a': '1.2.3', 'n8n-nodes-b': '4.5.6' }),
'utf-8',
);
});
});
describe('checkForMissingPackages', () => {
const installedPackage1 = mock<InstalledPackages>({
packageName: 'package-1',
installedVersion: '1.0.0',
installedNodes: [{ type: 'node-type-1' }],
});
const installedPackage2 = mock<InstalledPackages>({
packageName: 'package-2',
installedVersion: '2.0.0',
installedNodes: [{ type: 'node-type-2' }],
});
beforeEach(() => {
vi.spyOn(communityPackagesService, 'installPackage').mockResolvedValue(
{} as InstalledPackages,
);
vi.mocked(getCommunityNodeTypes).mockResolvedValue([]);
});
test('should not attempt to reinstall when no packages are missing', async () => {
const installedPackages = [installedPackage1];
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(true);
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).not.toHaveBeenCalled();
expect(loadNodesAndCredentials.postProcessLoaders).not.toHaveBeenCalled();
});
test('should not reinstall a package that still has one stale node row', async () => {
// A repair-reinstall leaves node rows from the previous version behind, so a package
// that dropped a node type keeps a row that never resolves. Reinstalling on that would
// run on every boot, and contradict the healthy status `withLoadStatus` reports.
const installedPackage = mock<InstalledPackages>({
packageName: 'package-1',
installedVersion: '1.0.0',
installedNodes: [{ type: 'old-dropped-node' }, { type: 'current-node' }],
});
installedPackageRepository.find.mockResolvedValue([installedPackage]);
loadNodesAndCredentials.isKnownNode.mockImplementation((type) => type === 'current-node');
config.reinstallMissing = true;
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
test('should identify missing packages without reinstalling when reinstallMissing is false', async () => {
const installedPackages = [installedPackage1, installedPackage2];
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockImplementation(
(nodeType) => nodeType === 'node-type-2',
);
config.reinstallMissing = false;
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).not.toHaveBeenCalled();
expect(loadNodesAndCredentials.postProcessLoaders).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
});
test('should reinstall missing packages when reinstallMissing is true', async () => {
const installedPackages = [installedPackage1];
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).toHaveBeenCalledWith(
'package-1',
'1.0.0',
undefined,
);
expect(loadNodesAndCredentials.postProcessLoaders).toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
'Packages reinstalled successfully. Resuming regular initialization.',
);
});
test('should not reinstall a package the startup guard skipped as incompatible', async () => {
// The package is on disk but declares a node API version this runtime
// does not support. Reinstalling the same version cannot fix it, and
// `loadPackage` would import its node code, bypassing the startup guard.
const installedPackages = [installedPackage1];
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
vi.mocked(readFile).mockResolvedValue(
JSON.stringify({
name: 'package-1',
version: '1.0.0',
n8n: { n8nNodesApiVersion: oneMinorAboveSupported },
}),
);
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Not reinstalling package "package-1"'),
);
expect(loadNodesAndCredentials.postProcessLoaders).not.toHaveBeenCalled();
});
test('should not reinstall a package with a malformed node API version on disk', async () => {
const installedPackages = [installedPackage1];
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
vi.mocked(readFile).mockResolvedValue(
JSON.stringify({ name: 'package-1', version: '1.0.0', n8n: { n8nNodesApiVersion: 3.1 } }),
);
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('invalid n8nNodesApiVersion'),
);
});
test('should reinstall a not-loaded package whose on-disk copy is compatible', async () => {
const installedPackages = [installedPackage1];
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
vi.mocked(readFile).mockResolvedValue(
JSON.stringify({
name: 'package-1',
version: '1.0.0',
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION },
}),
);
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).toHaveBeenCalledWith(
'package-1',
'1.0.0',
undefined,
);
});
test('should reinstall a package whose package.json is unreadable', async () => {
// An unreadable package.json is the partial/corrupt-install case:
// reinstall is the repair path, incompatibility is not the diagnosis.
const installedPackages = [installedPackage1];
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).toHaveBeenCalledWith(
'package-1',
'1.0.0',
undefined,
);
});
test('should log an error for a failed reinstallation', async () => {
const installedPackages = [installedPackage1];
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
communityPackagesService.installPackage = vi
.fn()
.mockRejectedValue(new Error('Installation failed'));
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).toHaveBeenCalledWith(
'package-1',
'1.0.0',
undefined,
);
expect(logger.error).toHaveBeenCalledWith(
'Failed to reinstall community package package-1: Installation failed',
);
});
test('should continue reinstalling remaining packages after one fails', async () => {
const installedPackages = [installedPackage1, installedPackage2];
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
// First installation fails, second succeeds
communityPackagesService.installPackage = vi
.fn()
.mockRejectedValueOnce(new Error('Installation failed'))
.mockResolvedValueOnce({} as InstalledPackages);
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).toHaveBeenCalledWith(
'package-1',
'1.0.0',
undefined,
);
expect(communityPackagesService.installPackage).toHaveBeenCalledWith(
'package-2',
'2.0.0',
undefined,
);
expect(logger.error).toHaveBeenCalledWith(
'Failed to reinstall community package package-1: Installation failed',
);
expect(loadNodesAndCredentials.postProcessLoaders).toHaveBeenCalled();
});
test('should pass checksum from vetted packages when reinstalling', async () => {
const installedPackages = [installedPackage1];
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
vi.mocked(getCommunityNodeTypes).mockResolvedValue([
{
packageName: 'package-1',
checksum: 'sha512-abc123',
npmVersion: '1.0.0',
} as never,
]);
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).toHaveBeenCalledWith(
'package-1',
'1.0.0',
'sha512-abc123',
);
});
test('should use version-specific checksum from nodeVersions when installed version differs from latest', async () => {
const installedPackages = [installedPackage1]; // version 1.0.0
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
vi.mocked(getCommunityNodeTypes).mockResolvedValue([
{
packageName: 'package-1',
checksum: 'sha512-latest',
npmVersion: '2.0.0',
nodeVersions: [
{ npmVersion: '1.0.0', checksum: 'sha512-version-specific' },
{ npmVersion: '2.0.0', checksum: 'sha512-latest' },
],
} as never,
]);
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).toHaveBeenCalledWith(
'package-1',
'1.0.0',
'sha512-version-specific',
);
});
test('should pass undefined checksum when installed version is not in vetted list', async () => {
const installedPackages = [installedPackage1]; // version 1.0.0
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
vi.mocked(getCommunityNodeTypes).mockResolvedValue([
{
packageName: 'package-1',
checksum: 'sha512-latest',
npmVersion: '2.0.0',
nodeVersions: [{ npmVersion: '2.0.0', checksum: 'sha512-latest' }],
} as never,
]);
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).toHaveBeenCalledWith(
'package-1',
'1.0.0',
undefined,
);
});
test('should pass undefined checksum when package is not in vetted list at all', async () => {
const installedPackages = [installedPackage1]; // version 1.0.0
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
// getCommunityNodeTypes returns empty array (package not vetted)
vi.mocked(getCommunityNodeTypes).mockResolvedValue([]);
await communityPackagesService.checkForMissingPackages();
expect(communityPackagesService.installPackage).toHaveBeenCalledWith(
'package-1',
'1.0.0',
undefined,
);
});
test('should handle multiple missing packages with mixed vetted status', async () => {
const installedPackages = [installedPackage1, installedPackage2];
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
// Mock getCommunityNodeTypes to return both packages in a single call
vi.mocked(getCommunityNodeTypes).mockResolvedValueOnce([
{
packageName: 'package-1',
checksum: 'sha512-package1',
npmVersion: '1.0.0',
} as never,
{
packageName: 'package-2',
checksum: 'sha512-package2',
npmVersion: '2.0.0',
} as never,
]);
await communityPackagesService.checkForMissingPackages();
// Both packages should be installed with their respective checksums
expect(communityPackagesService.installPackage).toHaveBeenCalledWith(
'package-1',
'1.0.0',
'sha512-package1',
);
expect(communityPackagesService.installPackage).toHaveBeenCalledWith(
'package-2',
'2.0.0',
'sha512-package2',
);
});
test('should call getCommunityNodeTypes with correct filters for each package', async () => {
const installedPackages = [installedPackage1];
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
vi.mocked(getCommunityNodeTypes).mockResolvedValue([]);
await communityPackagesService.checkForMissingPackages();
expect(getCommunityNodeTypes).toHaveBeenCalledWith(
'production',
{
filters: { packageName: { $in: ['package-1'] } },
fields: ['packageName', 'npmVersion', 'checksum', 'nodeVersions'],
},
config.aiNodeSdkVersion,
config.nodesApiVersion,
);
});
test('should use staging environment when ENVIRONMENT is set to staging', async () => {
const installedPackages = [installedPackage1];
const originalEnv = process.env.ENVIRONMENT;
installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
vi.mocked(getCommunityNodeTypes).mockResolvedValue([]);
process.env.ENVIRONMENT = 'staging';
try {
await communityPackagesService.checkForMissingPackages();
expect(getCommunityNodeTypes).toHaveBeenCalledWith(
'staging',
{
filters: { packageName: { $in: ['package-1'] } },
fields: ['packageName', 'npmVersion', 'checksum', 'nodeVersions'],
},
config.aiNodeSdkVersion,
config.nodesApiVersion,
);
} finally {
// Restore original environment
if (originalEnv === undefined) {
delete process.env.ENVIRONMENT;
} else {
process.env.ENVIRONMENT = originalEnv;
}
}
});
});
describe('updatePackageJsonDependency', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(readFile).mockResolvedValue(JSON.stringify({ dependencies: {} }));
});
test('should update package dependencies', async () => {
await communityPackagesService.updatePackageJsonDependency('test-package', '1.0.0');
expect(writeFile).toHaveBeenCalledWith(
path.join(nodesDownloadDir, 'package.json'),
JSON.stringify({ dependencies: { 'test-package': '1.0.0' } }, null, 2),
'utf-8',
);
});
test('should rebuild an unreadable ledger from the database and still apply the mutation', async () => {
// Without this the mutation throws on the corrupt file, so every install and
// uninstall stays broken until the process restarts.
vi.mocked(readFile).mockResolvedValue('{ "dependencies": { "n8n-nodes-a": "1.0.0"');
installedPackageRepository.find.mockResolvedValue([
mock<InstalledPackages>({ packageName: 'n8n-nodes-a', installedVersion: '1.0.0' }),
]);
const rebuiltPackageJson = JSON.stringify(
{
name: 'installed-nodes',
private: true,
dependencies: { 'n8n-nodes-a': '1.0.0', 'test-package': '1.0.0' },
},
null,
2,
);
await communityPackagesService.updatePackageJsonDependency('test-package', '1.0.0');
expect(writeFile).toHaveBeenCalledWith(
path.join(nodesDownloadDir, 'package.json'),
rebuiltPackageJson,
'utf-8',
);
});
});
});