From e054fc71cd272714fc81920e8d21fbbd77e98085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ir=C3=A9n=C3=A9e?= Date: Tue, 9 Sep 2025 08:32:04 +0100 Subject: [PATCH] fix(core): Force synchronize tag mappings also when no tag was updated (#19332) --- .../__tests__/source-control.service.test.ts | 83 ++++++++++++++++--- .../source-control.service.ee.ts | 8 +- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts index aa4feedcad..e01f0a1602 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts @@ -1,29 +1,38 @@ import type { SourceControlledFile } from '@n8n/api-types'; +import { isContainedWithin } from '@n8n/backend-common'; import { - type Variables, + type FolderRepository, type FolderWithWorkflowAndSubFolderCount, type TagEntity, - type User, - type FolderRepository, type TagRepository, + type User, + type Variables, type WorkflowEntity, - GLOBAL_MEMBER_ROLE, GLOBAL_ADMIN_ROLE, + GLOBAL_MEMBER_ROLE, } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; +import type { PushResult } from 'simple-git'; -import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; -import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; - +import type { SourceControlExportService } from '../source-control-export.service.ee'; import type { SourceControlGitService } from '../source-control-git.service.ee'; import type { SourceControlImportService } from '../source-control-import.service.ee'; import type { SourceControlScopedService } from '../source-control-scoped.service'; import type { StatusExportableCredential } from '../types/exportable-credential'; import type { SourceControlWorkflowVersionId } from '../types/source-control-workflow-version-id'; +import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; +import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import type { EventService } from '@/events/event.service'; + +jest.mock('@n8n/backend-common', () => ({ + ...jest.requireActual('@n8n/backend-common'), + isContainedWithin: jest.fn(() => true), +})); + describe('SourceControlService', () => { const preferencesService = new SourceControlPreferencesService( Container.get(InstanceSettings), @@ -33,20 +42,22 @@ describe('SourceControlService', () => { mock(), ); const sourceControlImportService = mock(); + const sourceControlExportService = mock(); const sourceControlScopedService = mock(); const tagRepository = mock(); const folderRepository = mock(); const gitService = mock(); + const eventService = mock(); const sourceControlService = new SourceControlService( mock(), gitService, preferencesService, - mock(), + sourceControlExportService, sourceControlImportService, sourceControlScopedService, tagRepository, folderRepository, - mock(), + eventService, ); beforeEach(() => { @@ -55,8 +66,10 @@ describe('SourceControlService', () => { }); describe('pushWorkfolder', () => { - it('should throw an error if a file is given that is not in the workfolder', async () => { + it('should throw an error if file path validation fails', async () => { const user = mock(); + (isContainedWithin as jest.Mock).mockReturnValueOnce(false); + await expect( sourceControlService.pushWorkfolder(user, { fileNames: [ @@ -75,6 +88,54 @@ describe('SourceControlService', () => { }), ).rejects.toThrow('File path /etc/passwd is invalid'); }); + + it('should include the tags file even if not explicitly specified', async () => { + // ARRANGE + const user = mock(); + const mockPushResult = mock(); + const mockFile: SourceControlledFile = { + file: 'some-workflow.json', + id: 'test', + name: 'some-workflow', + type: 'workflow', + status: 'modified', + location: 'local', + conflict: false, + updatedAt: new Date().toISOString(), + }; + + jest.spyOn(sourceControlService, 'getStatus').mockResolvedValueOnce([mockFile]); + sourceControlExportService.exportCredentialsToWorkFolder.mockResolvedValueOnce({ + count: 0, + missingIds: [], + folder: '', + files: [], + }); + eventService.emit.mockReturnValueOnce(true); + gitService.push.mockResolvedValueOnce(mockPushResult); + (isContainedWithin as jest.Mock).mockReturnValueOnce(true); + + const expectedTagsPath = `${preferencesService.gitFolder}/tags.json`; + const expectedFilePath = `${preferencesService.gitFolder}/some-workflow.json`; + + // ACT + const result = await sourceControlService.pushWorkfolder(user, { + fileNames: [mockFile], + commitMessage: 'A commit message', + }); + + // ASSERT + expect(gitService.stage).toHaveBeenCalledWith( + new Set([expectedFilePath, expectedTagsPath]), + new Set(), + ); + expect(gitService.commit).toHaveBeenCalledWith('A commit message'); + expect(gitService.push).toHaveBeenCalledWith({ + branch: 'main', // default branch + force: false, + }); + expect(result).toHaveProperty('statusCode', 200); + }); }); describe('pullWorkfolder', () => { diff --git a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts index 4275cd6dec..13ec8997ca 100644 --- a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts @@ -326,11 +326,9 @@ export class SourceControlService { }); } - const tagChanges = filesToPush.find((e) => e.type === 'tags'); - if (tagChanges) { - filesToBePushed.add(tagChanges.file); - await this.sourceControlExportService.exportTagsToWorkFolder(context); - } + // The tags file is always re-generated and exported to make sure the workflow-tag mappings are up to date + filesToBePushed.add(getTagsPath(this.gitFolder)); + await this.sourceControlExportService.exportTagsToWorkFolder(context); const folderChanges = filesToPush.find((e) => e.type === 'folders'); if (folderChanges) {