mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
fix(core): Skip npm outdated check for verified-only community packages (#28335)
This commit is contained in:
parent
36261fbe7a
commit
2959b4dc2a
5 changed files with 200 additions and 14 deletions
|
|
@ -9,6 +9,7 @@ import type { NodeRequest } from '@/requests';
|
|||
|
||||
import type { CommunityNodeTypesService } from '../community-node-types.service';
|
||||
import { CommunityPackagesController } from '../community-packages.controller';
|
||||
import type { CommunityPackagesConfig } from '../community-packages.config';
|
||||
import { CommunityPackagesLifecycleService } from '../community-packages.lifecycle.service';
|
||||
import type { CommunityPackagesService } from '../community-packages.service';
|
||||
import type { InstalledPackages } from '../installed-packages.entity';
|
||||
|
|
@ -21,6 +22,7 @@ describe('CommunityPackagesController', () => {
|
|||
const communityNodeTypesService = mock<CommunityNodeTypesService>();
|
||||
const instanceSettings = mock<InstanceSettings>();
|
||||
(instanceSettings as any).nodesDownloadDir = '/tmp/n8n-nodes-download';
|
||||
const communityPackagesConfig = mock<CommunityPackagesConfig>();
|
||||
|
||||
const lifecycle = new CommunityPackagesLifecycleService(
|
||||
logger,
|
||||
|
|
@ -29,6 +31,7 @@ describe('CommunityPackagesController', () => {
|
|||
eventService,
|
||||
communityNodeTypesService,
|
||||
instanceSettings,
|
||||
communityPackagesConfig,
|
||||
);
|
||||
|
||||
const controller = new CommunityPackagesController(lifecycle);
|
||||
|
|
|
|||
|
|
@ -7,9 +7,19 @@ import type { EventService } from '@/events/event.service';
|
|||
import type { Push } from '@/push';
|
||||
|
||||
import type { CommunityNodeTypesService } from '../community-node-types.service';
|
||||
import type { CommunityPackagesConfig } from '../community-packages.config';
|
||||
import { CommunityPackagesLifecycleService } from '../community-packages.lifecycle.service';
|
||||
import type { CommunityPackagesService } from '../community-packages.service';
|
||||
import type { InstalledPackages } from '../installed-packages.entity';
|
||||
import { executeNpmCommand } from '../npm-utils';
|
||||
|
||||
jest.mock('../npm-utils', () => ({
|
||||
...jest.requireActual('../npm-utils'),
|
||||
executeNpmCommand: jest.fn(),
|
||||
isNpmExecErrorWithStdout: jest.requireActual('../npm-utils').isNpmExecErrorWithStdout,
|
||||
}));
|
||||
|
||||
const mockedExecuteNpmCommand = jest.mocked(executeNpmCommand);
|
||||
|
||||
describe('CommunityPackagesLifecycleService', () => {
|
||||
const logger = mock<Logger>();
|
||||
|
|
@ -20,6 +30,9 @@ describe('CommunityPackagesLifecycleService', () => {
|
|||
const instanceSettings = mock<{ nodesDownloadDir: string }>({
|
||||
nodesDownloadDir: '/tmp/n8n-nodes-download',
|
||||
});
|
||||
const communityPackagesConfig = mock<CommunityPackagesConfig>({
|
||||
unverifiedEnabled: true,
|
||||
});
|
||||
|
||||
const lifecycle = new CommunityPackagesLifecycleService(
|
||||
logger,
|
||||
|
|
@ -28,6 +41,7 @@ describe('CommunityPackagesLifecycleService', () => {
|
|||
eventService,
|
||||
communityNodeTypesService,
|
||||
instanceSettings as never,
|
||||
communityPackagesConfig,
|
||||
);
|
||||
|
||||
const user = { id: 'user123' };
|
||||
|
|
@ -98,6 +112,107 @@ describe('CommunityPackagesLifecycleService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('listInstalledPackages', () => {
|
||||
const installedPackage = mock<InstalledPackages>({
|
||||
packageName: 'n8n-nodes-test',
|
||||
installedVersion: '1.0.0',
|
||||
installedNodes: [],
|
||||
});
|
||||
|
||||
it('should run npm outdated when unverifiedEnabled is true', async () => {
|
||||
communityPackagesConfig.unverifiedEnabled = true;
|
||||
communityPackagesService.getAllInstalledPackages.mockResolvedValue([installedPackage]);
|
||||
communityPackagesService.matchPackagesWithUpdates.mockReturnValue([
|
||||
{ ...installedPackage, updateAvailable: '2.0.0' },
|
||||
]);
|
||||
Object.defineProperty(communityPackagesService, 'hasMissingPackages', { value: false });
|
||||
|
||||
await lifecycle.listInstalledPackages();
|
||||
|
||||
expect(mockedExecuteNpmCommand).toHaveBeenCalledWith(['outdated', '--json'], {
|
||||
doNotHandleError: true,
|
||||
cwd: '/tmp/n8n-nodes-download',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not run npm outdated when unverifiedEnabled is false', async () => {
|
||||
communityPackagesConfig.unverifiedEnabled = false;
|
||||
communityPackagesService.getAllInstalledPackages.mockResolvedValue([installedPackage]);
|
||||
communityPackagesService.matchPackagesWithUpdates.mockReturnValue([installedPackage]);
|
||||
Object.defineProperty(communityPackagesService, 'hasMissingPackages', { value: false });
|
||||
|
||||
await lifecycle.listInstalledPackages();
|
||||
|
||||
expect(mockedExecuteNpmCommand).not.toHaveBeenCalled();
|
||||
expect(communityPackagesService.matchPackagesWithUpdates).toHaveBeenCalledWith(
|
||||
[installedPackage],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not report update for verified package at Strapi version when unverifiedEnabled is false', async () => {
|
||||
// Scenario: package installed at Strapi-vetted version (0.2.2), newer version (0.2.4) exists on npm.
|
||||
// With unverifiedEnabled=false, npm outdated is skipped so updateAvailable stays undefined.
|
||||
// Update detection in this mode is handled by the frontend via Strapi version comparison.
|
||||
communityPackagesConfig.unverifiedEnabled = false;
|
||||
const vettedPackage = mock<InstalledPackages>({
|
||||
packageName: 'n8n-nodes-elevenlabs',
|
||||
installedVersion: '0.2.2',
|
||||
installedNodes: [],
|
||||
});
|
||||
communityPackagesService.getAllInstalledPackages.mockResolvedValue([vettedPackage]);
|
||||
// Simulate real matchPackagesWithUpdates: without updates arg, returns packages without updateAvailable
|
||||
const returnedPackage = {
|
||||
packageName: 'n8n-nodes-elevenlabs',
|
||||
installedVersion: '0.2.2',
|
||||
installedNodes: [],
|
||||
};
|
||||
communityPackagesService.matchPackagesWithUpdates.mockReturnValue([returnedPackage as never]);
|
||||
Object.defineProperty(communityPackagesService, 'hasMissingPackages', { value: false });
|
||||
|
||||
const result = await lifecycle.listInstalledPackages();
|
||||
|
||||
expect(mockedExecuteNpmCommand).not.toHaveBeenCalled();
|
||||
expect(communityPackagesService.matchPackagesWithUpdates).toHaveBeenCalledWith(
|
||||
[vettedPackage],
|
||||
undefined,
|
||||
);
|
||||
// Package should not have updateAvailable — npm outdated was never consulted
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).not.toHaveProperty('updateAvailable');
|
||||
});
|
||||
|
||||
it('should return empty array when no packages are installed', async () => {
|
||||
communityPackagesService.getAllInstalledPackages.mockResolvedValue([]);
|
||||
|
||||
const result = await lifecycle.listInstalledPackages();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockedExecuteNpmCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should parse npm outdated output and pass updates to matchPackagesWithUpdates', async () => {
|
||||
communityPackagesConfig.unverifiedEnabled = true;
|
||||
communityPackagesService.getAllInstalledPackages.mockResolvedValue([installedPackage]);
|
||||
communityPackagesService.matchPackagesWithUpdates.mockReturnValue([installedPackage]);
|
||||
Object.defineProperty(communityPackagesService, 'hasMissingPackages', { value: false });
|
||||
|
||||
const npmOutdatedOutput = JSON.stringify({
|
||||
'n8n-nodes-test': { current: '1.0.0', wanted: '2.0.0', latest: '2.0.0' },
|
||||
});
|
||||
mockedExecuteNpmCommand.mockRejectedValue(
|
||||
Object.assign(new Error(), { code: 1, stdout: npmOutdatedOutput }),
|
||||
);
|
||||
|
||||
await lifecycle.listInstalledPackages();
|
||||
|
||||
expect(communityPackagesService.matchPackagesWithUpdates).toHaveBeenCalledWith(
|
||||
[installedPackage],
|
||||
{ 'n8n-nodes-test': { current: '1.0.0', wanted: '2.0.0', latest: '2.0.0' } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should use the version from the request when updating a package', async () => {
|
||||
const previouslyInstalledPackage = mock<InstalledPackages>({
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { InstanceSettings } from 'n8n-core';
|
|||
import { ensureError, jsonParse, type PublicInstalledPackage } from 'n8n-workflow';
|
||||
|
||||
import { CommunityNodeTypesService } from './community-node-types.service';
|
||||
import { CommunityPackagesConfig } from './community-packages.config';
|
||||
import { CommunityPackagesService, isValidVersionSpecifier } from './community-packages.service';
|
||||
import type { CommunityPackages } from './community-packages.types';
|
||||
import type { InstalledPackages } from './installed-packages.entity';
|
||||
|
|
@ -46,6 +47,7 @@ export class CommunityPackagesLifecycleService {
|
|||
private readonly eventService: EventService,
|
||||
private readonly communityNodeTypesService: CommunityNodeTypesService,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly communityPackagesConfig: CommunityPackagesConfig,
|
||||
) {}
|
||||
|
||||
async listInstalledPackages(): Promise<PublicInstalledPackage[] | InstalledPackages[]> {
|
||||
|
|
@ -55,19 +57,23 @@ export class CommunityPackagesLifecycleService {
|
|||
|
||||
let pendingUpdates: CommunityPackages.AvailableUpdates | undefined;
|
||||
|
||||
try {
|
||||
await executeNpmCommand(['outdated', '--json'], {
|
||||
doNotHandleError: true,
|
||||
cwd: this.instanceSettings.nodesDownloadDir,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isNpmExecErrorWithStdout(error) && error.code === 1) {
|
||||
try {
|
||||
pendingUpdates = jsonParse<CommunityPackages.AvailableUpdates>(error.stdout.trim());
|
||||
} catch (parseError) {
|
||||
this.logger.warn('Failed to parse npm outdated output', {
|
||||
error: ensureError(parseError),
|
||||
});
|
||||
// Only check npm registry for updates when unverified packages are enabled.
|
||||
// In verified-only mode, update availability is determined by Strapi CMS versions on the frontend.
|
||||
if (this.communityPackagesConfig.unverifiedEnabled) {
|
||||
try {
|
||||
await executeNpmCommand(['outdated', '--json'], {
|
||||
doNotHandleError: true,
|
||||
cwd: this.instanceSettings.nodesDownloadDir,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isNpmExecErrorWithStdout(error) && error.code === 1) {
|
||||
try {
|
||||
pendingUpdates = jsonParse<CommunityPackages.AvailableUpdates>(error.stdout.trim());
|
||||
} catch (parseError) {
|
||||
this.logger.warn('Failed to parse npm outdated output', {
|
||||
error: ensureError(parseError),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
|||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import type { CommunityNodeType } from '@n8n/api-types';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
|
@ -56,6 +57,7 @@ let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
|
|||
let communityNodesStore: ReturnType<typeof useCommunityNodesStore>;
|
||||
let credentialsStore: ReturnType<typeof useCredentialsStore>;
|
||||
let usersStore: ReturnType<typeof useUsersStore>;
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
let toast: ReturnType<typeof useToast>;
|
||||
let canvasOperations: ReturnType<typeof useCanvasOperations>;
|
||||
|
||||
|
|
@ -72,6 +74,7 @@ beforeEach(() => {
|
|||
credentialsStore = useCredentialsStore(pinia);
|
||||
workflowsStore = useWorkflowsStore(pinia);
|
||||
usersStore = useUsersStore(pinia);
|
||||
settingsStore = useSettingsStore(pinia);
|
||||
|
||||
canvasOperations = {
|
||||
initializeUnknownNodes,
|
||||
|
|
@ -226,6 +229,63 @@ describe('useInstallNode', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should install verified node with pinned version when unverified packages are disabled', async () => {
|
||||
Object.defineProperty(settingsStore, 'isUnverifiedPackagesEnabled', {
|
||||
value: false,
|
||||
writable: true,
|
||||
});
|
||||
const { installNode } = useInstallNode();
|
||||
|
||||
const result = await installNode({
|
||||
type: 'verified',
|
||||
packageName: 'test-package',
|
||||
nodeType: 'test-node',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenCalledWith('test-node');
|
||||
expect(communityNodesStore.installPackage).toHaveBeenCalledWith(
|
||||
'test-package',
|
||||
true,
|
||||
'1.0.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should install verified node as latest when unverified packages are enabled', async () => {
|
||||
Object.defineProperty(settingsStore, 'isUnverifiedPackagesEnabled', {
|
||||
value: true,
|
||||
writable: true,
|
||||
});
|
||||
const { installNode } = useInstallNode();
|
||||
|
||||
const result = await installNode({
|
||||
type: 'verified',
|
||||
packageName: 'test-package',
|
||||
nodeType: 'test-node',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(communityNodesStore.installPackage).toHaveBeenCalledWith('test-package');
|
||||
expect(nodeTypesStore.getCommunityNodeAttributes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should install unverified node without version regardless of unverifiedEnabled setting', async () => {
|
||||
Object.defineProperty(settingsStore, 'isUnverifiedPackagesEnabled', {
|
||||
value: false,
|
||||
writable: true,
|
||||
});
|
||||
const { installNode } = useInstallNode();
|
||||
|
||||
const result = await installNode({
|
||||
type: 'unverified',
|
||||
packageName: 'test-package',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(communityNodesStore.installPackage).toHaveBeenCalledWith('test-package');
|
||||
expect(nodeTypesStore.getCommunityNodeAttributes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should install unverified node without npm version', async () => {
|
||||
const { installNode } = useInstallNode();
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
|
||||
import { removePreviewToken } from '@/features/shared/nodeCreator/nodeCreator.utils';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
|
||||
type InstallNodeProps = {
|
||||
type: 'verified' | 'unverified';
|
||||
|
|
@ -53,6 +54,7 @@ export function useInstallNode() {
|
|||
const toast = useToast();
|
||||
const canvasOperations = useCanvasOperations();
|
||||
const telemetry = useTelemetry();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const getNpmVersion = async (key: string) => {
|
||||
const communityNodeAttributes = await nodeTypesStore.getCommunityNodeAttributes(key);
|
||||
|
|
@ -81,7 +83,7 @@ export function useInstallNode() {
|
|||
|
||||
try {
|
||||
loading.value = true;
|
||||
if (props.type === 'verified') {
|
||||
if (props.type === 'verified' && !settingsStore.isUnverifiedPackagesEnabled) {
|
||||
await communityNodesStore.installPackage(
|
||||
props.packageName,
|
||||
true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue