fix: Fallback to cli command if http request failed in npm-utils for community packages (#19413)

This commit is contained in:
Michael Kret 2025-10-01 18:07:39 +03:00 committed by GitHub
parent 16e4c7e16e
commit 2bde2e66cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 449 additions and 112 deletions

View File

@ -1,7 +1,27 @@
import { UnexpectedError } from 'n8n-workflow';
import nock from 'nock';
import { verifyIntegrity, isVersionExists } from '../npm-utils';
const mockAsyncExec = jest.fn();
jest.mock('node:child_process', () => ({
...jest.requireActual('node:child_process'),
execFile: jest.fn(),
}));
jest.mock('node:util', () => {
const actual = jest.requireActual('node:util');
return {
...actual,
promisify: jest.fn((fn) => {
if (fn === require('node:child_process').execFile) {
return mockAsyncExec;
}
return actual.promisify(fn);
}),
};
});
import { verifyIntegrity, checkIfVersionExistsOrThrow } from '../npm-utils';
describe('verifyIntegrity', () => {
const registryUrl = 'https://registry.npmjs.org';
@ -9,8 +29,14 @@ describe('verifyIntegrity', () => {
const version = '1.0.0';
const integrity = 'sha512-hash==';
beforeEach(() => {
jest.clearAllMocks();
mockAsyncExec.mockReset();
});
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
it('should verify integrity successfully', async () => {
@ -39,57 +65,36 @@ describe('verifyIntegrity', () => {
);
});
it('should throw error if metadata request fails', async () => {
it('should throw error if metadata request fails and CLI fallback also fails', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.reply(500);
await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow();
mockAsyncExec.mockRejectedValue(new Error('CLI command failed'));
await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow(
UnexpectedError,
);
});
it('should throw UnexpectedError and preserve original error as cause', async () => {
const integrity = 'sha512-somerandomhash==';
it('should throw UnexpectedError and preserve original error as cause when CLI fallback fails', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
try {
await verifyIntegrity(packageName, version, registryUrl, integrity);
throw new Error('Expected error was not thrown');
} catch (error: any) {
expect(error).toBeInstanceOf(UnexpectedError);
expect(error.message).toBe('Checksum verification failed');
expect(error.cause).toBeDefined();
expect(error.cause.message).toContain('Network failure');
}
mockAsyncExec.mockRejectedValue(new Error('CLI command failed'));
await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow(
new UnexpectedError('Checksum verification failed'),
);
});
it('should return generic message for DNS getaddrinfo errors', async () => {
const integrity = 'sha512-somerandomhash==';
it('should return generic message for DNS getaddrinfo errors when CLI fallback fails', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('getaddrinfo ENOTFOUND internal.registry.local');
try {
await verifyIntegrity(packageName, version, registryUrl, integrity);
throw new Error('Expected error was not thrown');
} catch (error: any) {
expect(error).toBeInstanceOf(UnexpectedError);
expect(error.message).toBe(
'Checksum verification failed. Please check your network connection and try again.',
);
expect(error.cause).toBeUndefined();
}
});
it('should return generic message for DNS ENOTFOUND errors', async () => {
const integrity = 'sha512-somerandomhash==';
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('ENOTFOUND some.internal.registry');
mockAsyncExec.mockRejectedValue(new Error('getaddrinfo ENOTFOUND registry.npmjs.org'));
await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow(
new UnexpectedError(
@ -97,15 +102,153 @@ describe('verifyIntegrity', () => {
),
);
});
it('should return generic message for DNS ENOTFOUND errors when CLI fallback fails', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('ENOTFOUND some.internal.registry');
mockAsyncExec.mockRejectedValue(new Error('ENOTFOUND registry.npmjs.org'));
await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow(
new UnexpectedError(
'Checksum verification failed. Please check your network connection and try again.',
),
);
});
describe('CLI fallback functionality', () => {
it('should fallback to npm CLI when HTTP request fails and succeed', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
mockAsyncExec.mockResolvedValue({
stdout: JSON.stringify(integrity),
stderr: '',
});
await expect(
verifyIntegrity(packageName, version, registryUrl, integrity),
).resolves.not.toThrow();
expect(mockAsyncExec).toHaveBeenCalledTimes(1);
expect(mockAsyncExec).toHaveBeenCalledWith('npm', [
'view',
`${packageName}@${version}`,
'dist.integrity',
`--registry=${registryUrl}`,
'--json',
]);
});
it('should fallback to npm CLI and throw error when integrity does not match', async () => {
const wrongIntegrity = 'sha512-wronghash==';
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
mockAsyncExec.mockResolvedValue({
stdout: JSON.stringify(wrongIntegrity),
stderr: '',
});
await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow(
new UnexpectedError('Checksum verification failed'),
);
expect(mockAsyncExec).toHaveBeenCalledTimes(1);
});
it('should handle special characters in package name and version safely', async () => {
const specialPackageName = 'test-package; rm -rf /';
const specialVersion = '1.0.0 && echo "hacked"';
nock(registryUrl)
.get(`/${encodeURIComponent(specialPackageName)}/${specialVersion}`)
.replyWithError('Network failure');
mockAsyncExec.mockResolvedValue({
stdout: JSON.stringify(integrity),
stderr: '',
});
await verifyIntegrity(specialPackageName, specialVersion, registryUrl, integrity);
expect(mockAsyncExec).toHaveBeenCalledTimes(1);
expect(mockAsyncExec).toHaveBeenCalledWith('npm', [
'view',
`${specialPackageName}@${specialVersion}`,
'dist.integrity',
`--registry=${registryUrl}`,
'--json',
]);
});
it('should handle DNS errors in CLI fallback', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
mockAsyncExec.mockRejectedValue(new Error('getaddrinfo ENOTFOUND registry.npmjs.org'));
await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow(
new UnexpectedError(
'Checksum verification failed. Please check your network connection and try again.',
),
);
expect(mockAsyncExec).toHaveBeenCalledTimes(1);
});
it('should handle npm errors in CLI fallback', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
mockAsyncExec.mockRejectedValue(
new Error('npm ERR! 404 Not Found - GET https://registry.npmjs.org/nonexistent-package'),
);
await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow(
new UnexpectedError(
'Checksum verification failed. Please check your network connection and try again.',
),
);
expect(mockAsyncExec).toHaveBeenCalledTimes(1);
});
it('should handle generic CLI errors', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
mockAsyncExec.mockRejectedValue(new Error('Some other error'));
await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow(
new UnexpectedError('Checksum verification failed'),
);
expect(mockAsyncExec).toHaveBeenCalledTimes(1);
});
});
});
describe('isVersionExists', () => {
describe('checkIfVersionExistsOrThrow', () => {
const registryUrl = 'https://registry.npmjs.org';
const packageName = 'test-package';
const version = '1.0.0';
beforeEach(() => {
jest.clearAllMocks();
mockAsyncExec.mockReset();
});
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
it('should return true when package version exists', async () => {
@ -116,92 +259,232 @@ describe('isVersionExists', () => {
version,
});
const result = await isVersionExists(packageName, version, registryUrl);
const result = await checkIfVersionExistsOrThrow(packageName, version, registryUrl);
expect(result).toBe(true);
});
it('should throw UnexpectedError when package version does not exist (404)', async () => {
it('should throw UnexpectedError when package version does not exist (404) and CLI fallback also fails', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.reply(404);
await expect(isVersionExists(packageName, version, registryUrl)).rejects.toThrow(
UnexpectedError,
mockAsyncExec.mockRejectedValue(new Error('E404 Not Found'));
await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow(
new UnexpectedError('Package version does not exist'),
);
});
it('should throw UnexpectedError with proper message on 404', async () => {
it('should throw UnexpectedError with proper message on 404 when CLI fallback fails', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.reply(404);
try {
await isVersionExists(packageName, version, registryUrl);
throw new Error('Expected error was not thrown');
} catch (error: any) {
expect(error).toBeInstanceOf(UnexpectedError);
expect(error.message).toBe('Package version does not exist');
expect(error.cause).toBeDefined();
}
mockAsyncExec.mockRejectedValue(new Error('Some error'));
await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow(
new UnexpectedError('Failed to check package version existence'),
);
});
it('should throw UnexpectedError for network failures', async () => {
it('should throw UnexpectedError for network failures when CLI fallback fails', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
try {
await isVersionExists(packageName, version, registryUrl);
throw new Error('Expected error was not thrown');
} catch (error: any) {
expect(error).toBeInstanceOf(UnexpectedError);
expect(error.message).toBe('Failed to check package version existence');
expect(error.cause).toBeDefined();
expect(error.cause.message).toContain('Network failure');
}
mockAsyncExec.mockRejectedValue(new Error('CLI network failure'));
await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow(
new UnexpectedError('Failed to check package version existence'),
);
});
it('should throw UnexpectedError for server errors (500)', async () => {
it('should throw UnexpectedError for server errors (500) when CLI fallback fails', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.reply(500);
await expect(isVersionExists(packageName, version, registryUrl)).rejects.toThrow(
mockAsyncExec.mockRejectedValue(new Error('CLI error'));
await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow(
UnexpectedError,
);
});
it('should return generic message for DNS getaddrinfo errors', async () => {
it('should return generic message for DNS getaddrinfo errors when CLI fallback fails', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('getaddrinfo ENOTFOUND internal.registry.local');
try {
await isVersionExists(packageName, version, registryUrl);
throw new Error('Expected error was not thrown');
} catch (error: any) {
expect(error).toBeInstanceOf(UnexpectedError);
expect(error.message).toBe(
mockAsyncExec.mockRejectedValue(new Error('getaddrinfo ENOTFOUND registry.npmjs.org'));
await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow(
new UnexpectedError(
'The community nodes service is temporarily unreachable. Please try again later.',
);
expect(error.cause).toBeUndefined();
}
),
);
});
it('should return generic message for DNS ENOTFOUND errors', async () => {
it('should return generic message for DNS ENOTFOUND errors when CLI fallback fails', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('ENOTFOUND some.internal.registry');
try {
await isVersionExists(packageName, version, registryUrl);
throw new Error('Expected error was not thrown');
} catch (error: any) {
expect(error).toBeInstanceOf(UnexpectedError);
expect(error.message).toBe(
mockAsyncExec.mockRejectedValue(new Error('ENOTFOUND registry.npmjs.org'));
await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow(
new UnexpectedError(
'The community nodes service is temporarily unreachable. Please try again later.',
),
);
});
describe('CLI fallback functionality', () => {
it('should fallback to npm CLI when HTTP request fails and return true', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
mockAsyncExec.mockResolvedValue({
stdout: JSON.stringify(version),
stderr: '',
});
const result = await checkIfVersionExistsOrThrow(packageName, version, registryUrl);
expect(result).toBe(true);
expect(mockAsyncExec).toHaveBeenCalledTimes(1);
expect(mockAsyncExec).toHaveBeenCalledWith('npm', [
'view',
`${packageName}@${version}`,
'version',
`--registry=${registryUrl}`,
'--json',
]);
});
it('should fallback to npm CLI and throw error when version does not match', async () => {
const differentVersion = '2.0.0';
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
mockAsyncExec.mockResolvedValue({
stdout: JSON.stringify(differentVersion),
stderr: '',
});
await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow(
new UnexpectedError('Failed to check package version existence'),
);
expect(error.cause).toBeUndefined();
}
expect(mockAsyncExec).toHaveBeenCalledTimes(1);
});
it('should handle special characters in package name and version safely for checkIfVersionExistsOrThrow', async () => {
const specialPackageName = 'test-package; rm -rf /';
const specialVersion = '1.0.0 && echo "hacked"';
nock(registryUrl)
.get(`/${encodeURIComponent(specialPackageName)}/${specialVersion}`)
.replyWithError('Network failure');
mockAsyncExec.mockResolvedValue({
stdout: JSON.stringify(specialVersion),
stderr: '',
});
const result = await checkIfVersionExistsOrThrow(
specialPackageName,
specialVersion,
registryUrl,
);
expect(result).toBe(true);
expect(mockAsyncExec).toHaveBeenCalledTimes(1);
expect(mockAsyncExec).toHaveBeenCalledWith('npm', [
'view',
`${specialPackageName}@${specialVersion}`,
'version',
`--registry=${registryUrl}`,
'--json',
]);
});
it('should handle 404 errors in CLI fallback', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
mockAsyncExec.mockRejectedValue(
new Error('E404 Not Found - GET https://registry.npmjs.org/nonexistent-package'),
);
await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow(
new UnexpectedError('Package version does not exist'),
);
expect(mockAsyncExec).toHaveBeenCalledTimes(1);
});
it('should handle DNS errors in CLI fallback for checkIfVersionExistsOrThrow', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
mockAsyncExec.mockRejectedValue(new Error('getaddrinfo ENOTFOUND registry.npmjs.org'));
await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow(
new UnexpectedError(
'The community nodes service is temporarily unreachable. Please try again later.',
),
);
expect(mockAsyncExec).toHaveBeenCalledTimes(1);
});
it('should handle npm errors in CLI fallback for checkIfVersionExistsOrThrow', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
mockAsyncExec.mockRejectedValue(new Error('npm ERR! 500 Internal Server Error'));
await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow(
new UnexpectedError(
'The community nodes service is temporarily unreachable. Please try again later.',
),
);
expect(mockAsyncExec).toHaveBeenCalledTimes(1);
});
it('should handle generic CLI errors for checkIfVersionExistsOrThrow', async () => {
nock(registryUrl)
.get(`/${encodeURIComponent(packageName)}/${version}`)
.replyWithError('Network failure');
mockAsyncExec.mockRejectedValue(new Error('Some other error'));
await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow(
new UnexpectedError('Failed to check package version existence'),
);
expect(mockAsyncExec).toHaveBeenCalledTimes(1);
});
});
describe('Helper functions', () => {
it('should sanitize registry URL by removing trailing slashes', async () => {
const registryWithSlashes = 'https://registry.npmjs.org///';
nock('https://registry.npmjs.org')
.get(`/${encodeURIComponent(packageName)}/${version}`)
.reply(200, {
name: packageName,
version,
});
const result = await checkIfVersionExistsOrThrow(packageName, version, registryWithSlashes);
expect(result).toBe(true);
});
});
});

View File

@ -28,7 +28,7 @@ import { CommunityPackagesConfig } from './community-packages.config';
import type { CommunityPackages } from './community-packages.types';
import { InstalledPackages } from './installed-packages.entity';
import { InstalledPackagesRepository } from './installed-packages.repository';
import { isVersionExists, verifyIntegrity } from './npm-utils';
import { checkIfVersionExistsOrThrow, verifyIntegrity } from './npm-utils';
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
const NPM_COMMON_ARGS = ['--audit=false', '--fund=false'];
@ -398,7 +398,7 @@ export class CommunityPackagesService {
await verifyIntegrity(packageName, packageVersion, this.getNpmRegistry(), options.checksum);
}
await isVersionExists(packageName, packageVersion, this.getNpmRegistry());
await checkIfVersionExistsOrThrow(packageName, packageVersion, this.getNpmRegistry());
try {
await this.downloadPackage(packageName, packageVersion);

View File

@ -1,12 +1,30 @@
import axios from 'axios';
import { UnexpectedError } from 'n8n-workflow';
import { jsonParse, UnexpectedError } from 'n8n-workflow';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const asyncExecFile = promisify(execFile);
const REQUEST_TIMEOUT = 30000;
function isDnsError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes('getaddrinfo') || message.includes('ENOTFOUND');
}
const REQUEST_TIMEOUT = 30000;
function isNpmError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('npm ERR!') ||
message.includes('E404') ||
message.includes('404 Not Found') ||
message.includes('ENOTFOUND')
);
}
function sanitizeRegistryUrl(registryUrl: string): string {
return registryUrl.replace(/\/+$/, '');
}
export async function verifyIntegrity(
packageName: string,
@ -14,50 +32,86 @@ export async function verifyIntegrity(
registryUrl: string,
expectedIntegrity: string,
) {
const timeoutOption = { timeout: REQUEST_TIMEOUT };
const url = `${sanitizeRegistryUrl(registryUrl)}/${encodeURIComponent(packageName)}`;
try {
const url = `${registryUrl.replace(/\/+$/, '')}/${encodeURIComponent(packageName)}`;
const metadata = await axios.get<{ dist: { integrity: string } }>(
`${url}/${version}`,
timeoutOption,
);
const metadata = await axios.get<{ dist: { integrity?: string } }>(`${url}/${version}`, {
timeout: REQUEST_TIMEOUT,
});
if (metadata?.data?.dist?.integrity !== expectedIntegrity) {
const integrity = metadata?.data?.dist?.integrity;
if (integrity !== expectedIntegrity) {
throw new UnexpectedError('Checksum verification failed. Package integrity does not match.');
}
return;
} catch (error) {
if (isDnsError(error)) {
throw new UnexpectedError(
'Checksum verification failed. Please check your network connection and try again.',
);
try {
const { stdout } = await asyncExecFile('npm', [
'view',
`${packageName}@${version}`,
'dist.integrity',
`--registry=${sanitizeRegistryUrl(registryUrl)}`,
'--json',
]);
const integrity = jsonParse(stdout);
if (integrity !== expectedIntegrity) {
throw new UnexpectedError(
'Checksum verification failed. Package integrity does not match.',
);
}
return;
} catch (cliError) {
if (isDnsError(cliError) || isNpmError(cliError)) {
throw new UnexpectedError(
'Checksum verification failed. Please check your network connection and try again.',
);
}
throw new UnexpectedError('Checksum verification failed');
}
throw new UnexpectedError('Checksum verification failed', { cause: error });
}
}
export async function isVersionExists(
export async function checkIfVersionExistsOrThrow(
packageName: string,
version: string,
registryUrl: string,
): Promise<boolean> {
const timeoutOption = { timeout: REQUEST_TIMEOUT };
): Promise<true> {
const url = `${sanitizeRegistryUrl(registryUrl)}/${encodeURIComponent(packageName)}`;
try {
const url = `${registryUrl.replace(/\/+$/, '')}/${encodeURIComponent(packageName)}`;
await axios.get(`${url}/${version}`, timeoutOption);
await axios.get(`${url}/${version}`, { timeout: REQUEST_TIMEOUT });
return true;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
throw new UnexpectedError('Package version does not exist', {
cause: error,
});
try {
const { stdout } = await asyncExecFile('npm', [
'view',
`${packageName}@${version}`,
'version',
`--registry=${sanitizeRegistryUrl(registryUrl)}`,
'--json',
]);
const versionInfo = jsonParse(stdout);
if (versionInfo === version) {
return true;
}
throw new UnexpectedError('Failed to check package version existence');
} catch (cliError) {
const message = cliError instanceof Error ? cliError.message : String(cliError);
if (message.includes('E404') || message.includes('404 Not Found')) {
throw new UnexpectedError('Package version does not exist');
}
if (isDnsError(cliError) || isNpmError(cliError)) {
throw new UnexpectedError(
'The community nodes service is temporarily unreachable. Please try again later.',
);
}
throw new UnexpectedError('Failed to check package version existence');
}
if (isDnsError(error)) {
throw new UnexpectedError(
'The community nodes service is temporarily unreachable. Please try again later.',
);
}
throw new UnexpectedError('Failed to check package version existence', { cause: error });
}
}