refactor(core): Modularize community packages (#18641)

This commit is contained in:
Iván Ovejero
2025-08-22 12:19:01 +02:00
committed by GitHub
parent e3772c13d2
commit 9e420d15c1
43 changed files with 144 additions and 98 deletions

View File

@@ -12,11 +12,11 @@ import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-rela
import { ExternalHooks } from '@/external-hooks';
import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { CommunityPackagesService } from '@/modules/community-packages/community-packages.service';
import { Push } from '@/push';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import { ScalingService } from '@/scaling/scaling.service';
import { CommunityPackagesService } from '@/community-packages/community-packages.service';
import { TaskBrokerServer } from '@/task-runners/task-broker/task-broker-server';
import { TaskRunnerProcess } from '@/task-runners/task-runner-process';
import { Telemetry } from '@/telemetry';

View File

@@ -1,246 +0,0 @@
import { mockInstance } from '@n8n/backend-test-utils';
import type { InstalledNodes, InstalledPackages } from '@n8n/db';
import path from 'path';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { CommunityPackagesService } from '@/community-packages/community-packages.service';
import { COMMUNITY_PACKAGE_VERSION } from './shared/constants';
import { createOwner } from './shared/db/users';
import type { SuperAgentTest } from './shared/types';
import { setupTestServer, mockPackage, mockNode, mockPackageName } from './shared/utils';
const communityPackagesService = mockInstance(CommunityPackagesService, {
hasMissingPackages: false,
});
mockInstance(LoadNodesAndCredentials);
const testServer = setupTestServer({ endpointGroups: ['community-packages'] });
const commonUpdatesProps = {
createdAt: new Date(),
updatedAt: new Date(),
installedVersion: COMMUNITY_PACKAGE_VERSION.CURRENT,
updateAvailable: COMMUNITY_PACKAGE_VERSION.UPDATED,
};
const parsedNpmPackageName = {
packageName: 'test',
rawString: 'test',
};
let authAgent: SuperAgentTest;
beforeAll(async () => {
const ownerShell = await createOwner();
authAgent = testServer.authAgentFor(ownerShell);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('GET /community-packages', () => {
test('should respond 200 if no nodes are installed', async () => {
communityPackagesService.getAllInstalledPackages.mockResolvedValue([]);
const {
body: { data },
} = await authAgent.get('/community-packages').expect(200);
expect(data).toHaveLength(0);
});
test('should return list of one installed package and node', async () => {
const pkg = mockPackage();
const node = mockNode(pkg.packageName);
pkg.installedNodes = [node];
communityPackagesService.getAllInstalledPackages.mockResolvedValue([pkg]);
communityPackagesService.matchPackagesWithUpdates.mockReturnValue([pkg]);
const {
body: { data },
} = await authAgent.get('/community-packages').expect(200);
expect(data).toHaveLength(1);
expect(data[0].installedNodes).toHaveLength(1);
});
test('should return list of multiple installed packages and nodes', async () => {
const pkgA = mockPackage();
const nodeA = mockNode(pkgA.packageName);
const pkgB = mockPackage();
const nodeB = mockNode(pkgB.packageName);
const nodeC = mockNode(pkgB.packageName);
communityPackagesService.getAllInstalledPackages.mockResolvedValue([pkgA, pkgB]);
communityPackagesService.matchPackagesWithUpdates.mockReturnValue([
{
...commonUpdatesProps,
packageName: pkgA.packageName,
installedNodes: [nodeA],
},
{
...commonUpdatesProps,
packageName: pkgB.packageName,
installedNodes: [nodeB, nodeC],
},
]);
const {
body: { data },
} = await authAgent.get('/community-packages').expect(200);
expect(data).toHaveLength(2);
const allNodes = data.reduce(
(acc: InstalledNodes[], cur: InstalledPackages) => acc.concat(cur.installedNodes),
[],
);
expect(allNodes).toHaveLength(3);
});
test('should not check for updates if no packages installed', async () => {
await authAgent.get('/community-packages');
expect(communityPackagesService.executeNpmCommand).not.toHaveBeenCalled();
});
test('should check for updates if packages installed', async () => {
communityPackagesService.getAllInstalledPackages.mockResolvedValue([mockPackage()]);
await authAgent.get('/community-packages').expect(200);
const args = ['npm outdated --json', { doNotHandleError: true }];
expect(communityPackagesService.executeNpmCommand).toHaveBeenCalledWith(...args);
});
test('should report package updates if available', async () => {
const pkg = mockPackage();
communityPackagesService.getAllInstalledPackages.mockResolvedValue([pkg]);
communityPackagesService.executeNpmCommand.mockImplementation(() => {
throw {
code: 1,
stdout: JSON.stringify({
[pkg.packageName]: {
current: COMMUNITY_PACKAGE_VERSION.CURRENT,
wanted: COMMUNITY_PACKAGE_VERSION.CURRENT,
latest: COMMUNITY_PACKAGE_VERSION.UPDATED,
location: path.join('node_modules', pkg.packageName),
},
}),
};
});
communityPackagesService.matchPackagesWithUpdates.mockReturnValue([
{
packageName: 'test',
installedNodes: [],
...commonUpdatesProps,
},
]);
const {
body: { data },
} = await authAgent.get('/community-packages').expect(200);
const [returnedPkg] = data;
expect(returnedPkg.installedVersion).toBe(COMMUNITY_PACKAGE_VERSION.CURRENT);
expect(returnedPkg.updateAvailable).toBe(COMMUNITY_PACKAGE_VERSION.UPDATED);
});
});
describe('POST /community-packages', () => {
test('should reject if package name is missing', async () => {
await authAgent.post('/community-packages').expect(400);
});
test('should reject if package is duplicate', async () => {
communityPackagesService.findInstalledPackage.mockResolvedValue(mockPackage());
communityPackagesService.isPackageInstalled.mockResolvedValue(true);
communityPackagesService.hasPackageLoaded.mockReturnValue(true);
communityPackagesService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName);
const {
body: { message },
} = await authAgent.post('/community-packages').send({ name: mockPackageName() }).expect(400);
expect(message).toContain('already installed');
});
test('should allow installing packages that could not be loaded', async () => {
communityPackagesService.findInstalledPackage.mockResolvedValue(mockPackage());
communityPackagesService.hasPackageLoaded.mockReturnValue(false);
communityPackagesService.checkNpmPackageStatus.mockResolvedValue({ status: 'OK' });
communityPackagesService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName);
communityPackagesService.installPackage.mockResolvedValue(mockPackage());
await authAgent.post('/community-packages').send({ name: mockPackageName() }).expect(200);
expect(communityPackagesService.removePackageFromMissingList).toHaveBeenCalled();
});
test('should not install a banned package', async () => {
communityPackagesService.checkNpmPackageStatus.mockResolvedValue({ status: 'Banned' });
communityPackagesService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName);
const {
body: { message },
} = await authAgent.post('/community-packages').send({ name: mockPackageName() }).expect(400);
expect(message).toContain('banned');
});
});
describe('DELETE /community-packages', () => {
test('should not delete if package name is empty', async () => {
await authAgent.delete('/community-packages').expect(400);
});
test('should reject if package is not installed', async () => {
const {
body: { message },
} = await authAgent
.delete('/community-packages')
.query({ name: mockPackageName() })
.expect(400);
expect(message).toContain('not installed');
});
test('should uninstall package', async () => {
communityPackagesService.findInstalledPackage.mockResolvedValue(mockPackage());
await authAgent.delete('/community-packages').query({ name: mockPackageName() }).expect(200);
expect(communityPackagesService.removePackage).toHaveBeenCalledTimes(1);
});
});
describe('PATCH /community-packages', () => {
test('should reject if package name is empty', async () => {
await authAgent.patch('/community-packages').expect(400);
});
test('should reject if package is not installed', async () => {
const {
body: { message },
} = await authAgent.patch('/community-packages').send({ name: mockPackageName() }).expect(400);
expect(message).toContain('not installed');
});
test('should update a package', async () => {
communityPackagesService.findInstalledPackage.mockResolvedValue(mockPackage());
communityPackagesService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName);
await authAgent.patch('/community-packages').send({ name: mockPackageName() });
expect(communityPackagesService.updatePackage).toHaveBeenCalledTimes(1);
});
});

View File

@@ -5,12 +5,12 @@ import { mock } from 'jest-mock-extended';
import { v4 as uuid } from 'uuid';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { CommunityPackagesService } from '@/modules/community-packages/community-packages.service';
import { NodeTypes } from '@/node-types';
import { OFFICIAL_RISKY_NODE_TYPES, NODES_REPORT } from '@/security-audit/constants';
import { PackagesRepository } from '@/security-audit/security-audit.repository';
import { SecurityAuditService } from '@/security-audit/security-audit.service';
import { toReportTitle } from '@/security-audit/utils';
import { CommunityPackagesService } from '@/community-packages/community-packages.service';
import { getRiskSection, MOCK_PACKAGE, saveManualTriggerWorkflow } from './utils';

View File

@@ -1,11 +1,12 @@
import { GlobalConfig } from '@n8n/config';
import type { InstalledNodes, InstalledPackages } from '@n8n/db';
import { WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import nock from 'nock';
import { v4 as uuid } from 'uuid';
import * as constants from '@/constants';
import type { InstalledNodes } from '@/modules/community-packages/installed-nodes.entity';
import type { InstalledPackages } from '@/modules/community-packages/installed-packages.entity';
import type { Risk } from '@/security-audit/types';
import { toReportTitle } from '@/security-audit/utils';

View File

@@ -46,7 +46,7 @@ type EndpointGroup =
| 'data-store'
| 'module-settings';
type ModuleName = 'insights' | 'external-secrets' | 'data-store';
type ModuleName = 'insights' | 'external-secrets' | 'community-packages' | 'data-store';
export interface SetupProps {
endpointGroups?: EndpointGroup[];

View File

@@ -1,8 +1,10 @@
import { randomName } from '@n8n/backend-test-utils';
import { InstalledPackages, InstalledNodesRepository, InstalledPackagesRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { NODE_PACKAGE_PREFIX } from '@/constants';
import { InstalledNodesRepository } from '@/modules/community-packages/installed-nodes.repository';
import { InstalledPackages } from '@/modules/community-packages/installed-packages.entity';
import { InstalledPackagesRepository } from '@/modules/community-packages/installed-packages.repository';
import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '../constants';

View File

@@ -232,7 +232,7 @@ export const setupTestServer = ({
break;
case 'community-packages':
await import('@/community-packages/community-packages.controller');
await import('@/modules/community-packages/community-packages.controller');
break;
case 'me':