fix(core): Skip npm outdated check for verified-only community packages (#28335)

This commit is contained in:
Bernhard Wittmann 2026-04-14 15:09:13 +02:00 committed by GitHub
parent 36261fbe7a
commit 2959b4dc2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 200 additions and 14 deletions

View file

@ -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);

View file

@ -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>({

View file

@ -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),
});
}
}
}
}

View file

@ -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();

View file

@ -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,