From 3bab056b42e8d8654f2ec8d8b0f84274944809ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 15 May 2026 15:00:30 +0200 Subject: [PATCH] fix(core): Bypass archived workflows in activation (#30517) --- .../src/repositories/workflow.repository.ts | 27 +++++----- .../__tests__/active-workflow-manager.test.ts | 25 ++++++++++ packages/cli/src/active-workflow-manager.ts | 5 ++ .../source-control-import.service.ee.test.ts | 50 +++++++++++++++++++ .../source-control-import.service.ee.ts | 15 ++++-- .../repositories/workflow.repository.test.ts | 34 +++++++++++++ .../source-control-import.service.test.ts | 33 ++++++++++++ 7 files changed, 172 insertions(+), 17 deletions(-) diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index d97eae5c0ed..575e87a7158 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -78,7 +78,7 @@ export class WorkflowRepository extends Repository { async getAllActiveIds() { const result = await this.find({ select: { id: true }, - where: { activeVersionId: Not(IsNull()) }, + where: { activeVersionId: Not(IsNull()), isArchived: false }, }); return result.map(({ id }) => id); @@ -1294,20 +1294,23 @@ export class WorkflowRepository extends Repository { * @param versionId - The ID of the version to publish (optional; if not provided, uses the current version) * */ async publishVersion(workflowId: string, versionId?: string) { - let versionIdToPublish = versionId; - if (!versionIdToPublish) { - const workflow = await this.findOne({ - where: { id: workflowId }, - select: ['id', 'versionId'], - }); + const workflow = await this.findOne({ + where: { id: workflowId }, + select: ['id', 'versionId', 'isArchived'], + }); - if (!workflow) { - throw new UserError(`Workflow "${workflowId}" not found.`); - } - - versionIdToPublish = workflow.versionId; + if (!workflow) { + throw new UserError(`Workflow "${workflowId}" not found.`); } + if (workflow.isArchived) { + throw new UserError('Cannot publish archived Workflow', { + extra: { workflowId }, + }); + } + + const versionIdToPublish = versionId ?? workflow.versionId; + const version = await this.workflowHistoryRepository.findOneBy({ workflowId, versionId: versionIdToPublish, diff --git a/packages/cli/src/__tests__/active-workflow-manager.test.ts b/packages/cli/src/__tests__/active-workflow-manager.test.ts index 63a975cc2ba..61e397425a7 100644 --- a/packages/cli/src/__tests__/active-workflow-manager.test.ts +++ b/packages/cli/src/__tests__/active-workflow-manager.test.ts @@ -120,6 +120,31 @@ describe('ActiveWorkflowManager', () => { expect(added).toEqual({ triggersAndPollers: false, webhooks: false }); }, ); + + test.each<[WorkflowActivateMode]>([['init'], ['leadershipChange'], ['activate']])( + 'should skip archived workflow in `%s` activation mode', + async (mode) => { + const addWebhooksSpy = jest.spyOn(activeWorkflowManager, 'addWebhooks'); + const addTriggersAndPollersSpy = jest.spyOn( + activeWorkflowManager, + 'addTriggersAndPollers', + ); + workflowRepository.findById.mockResolvedValue( + mock({ + id: 'archived-id', + active: true, + activeVersionId: 'v1', + isArchived: true, + }), + ); + + const added = await activeWorkflowManager.add('archived-id', mode); + + expect(addWebhooksSpy).not.toHaveBeenCalled(); + expect(addTriggersAndPollersSpy).not.toHaveBeenCalled(); + expect(added).toEqual({ triggersAndPollers: false, webhooks: false }); + }, + ); }); }); diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index b3f81bc7038..066836eb159 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -652,6 +652,11 @@ export class ActiveWorkflowManager { }); } + if (dbWorkflow.isArchived) { + this.logger.debug('Cannot publish archived Workflow', { workflowId: dbWorkflow.id }); + return added; + } + if (this.instanceSettings.isMultiMain && shouldPublish) { if (!dbWorkflow?.activeVersionId) { throw new UnexpectedError('Active version ID not found for workflow', { diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts index 31526404a35..f94abfec026 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts @@ -544,6 +544,56 @@ describe('SourceControlImportService', () => { expect(workflowService.activateWorkflow).not.toHaveBeenCalled(); }); + it('should clear active state when local workflow is archived with a lingering active version', async () => { + const mockUserId = 'user-id-123'; + const mockUser = Object.assign(new User(), { id: mockUserId }); + const mockWorkflowFile = '/mock/workflow1.json'; + const mockWorkflowData = { + id: 'workflow1', + name: 'Workflow', + nodes: [], + connections: {}, + versionId: 'v2', + parentFolderId: null, + }; + const candidates = [mock({ file: mockWorkflowFile, id: 'workflow1' })]; + + userRepository.findOne.mockResolvedValue(mockUser); + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue( + Object.assign(new Project(), { id: 'project1', type: 'personal' }), + ); + workflowRepository.findByIds.mockResolvedValue([ + Object.assign(new WorkflowEntity(), { + id: 'workflow1', + name: 'Workflow', + active: true, + activeVersionId: 'v1', + isArchived: true, + }), + ]); + folderRepository.find.mockResolvedValue([]); + sharedWorkflowRepository.findWithFields.mockResolvedValue([]); + workflowRepository.upsert.mockResolvedValue({ + identifiers: [{ id: 'workflow1' }], + generatedMaps: [], + raw: [], + }); + + fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); + + await service.importWorkflowFromWorkFolder(candidates, mockUserId, 'none'); + + expect(workflowRepository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'workflow1', + active: false, + activeVersionId: null, + }), + ['id'], + ); + expect(workflowService.activateWorkflow).not.toHaveBeenCalled(); + }); + it('should unpublish archived workflows even if they were previously published', async () => { const mockUserId = 'user-id-123'; const mockUser = Object.assign(new User(), { id: mockUserId }); diff --git a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts index 6e14f4ce1a5..7cf5ffc3dcb 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts @@ -683,7 +683,7 @@ export class SourceControlImportService { const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId); const candidateIds = candidates.map((c) => c.id); const existingWorkflows = await this.workflowRepository.findByIds(candidateIds, { - fields: ['id', 'name', 'versionId', 'active', 'activeVersionId'], + fields: ['id', 'name', 'versionId', 'active', 'activeVersionId', 'isArchived'], }); const folders = await this.folderRepository.find({ select: ['id'] }); @@ -1761,7 +1761,7 @@ export class SourceControlImportService { this.resolvePublishedStatus( importedWorkflow, - existingWorkflow?.activeVersionId, + existingWorkflow, mustUnpublishLocal, unpublishedLocal, ); @@ -1773,7 +1773,7 @@ export class SourceControlImportService { * Resolves the publish status for the upsert of the imported workflow. * We set active to false here and handle publishing after upsert. * @param importedWorkflow The imported workflow. - * @param existingWorkflowActiveVersionId The existing workflow active version id, if it exists. + * @param existingWorkflow The existing workflow entity, if it exists. * @param mustUnpublishLocal Whether the local workflow must be unpublished. * @param unpublishedLocal Whether the local workflow was unpublished. */ @@ -1781,12 +1781,17 @@ export class SourceControlImportService { // Note: Workflow's active status is not saved in the remote workflow files, // and the field is missing despite IWorkflowToImport having it typed as boolean. importedWorkflow: IWorkflowToImport, - existingWorkflowActiveVersionId: string | null | undefined, + existingWorkflow: WorkflowEntity | undefined, mustUnpublishLocal: boolean, unpublishedLocal: boolean, ) { + const existingWorkflowActiveVersionId = existingWorkflow?.activeVersionId; + const isExistingArchived = !!existingWorkflow?.isArchived; + const shouldPreserve = - !!existingWorkflowActiveVersionId && (!mustUnpublishLocal || !unpublishedLocal); + !isExistingArchived && + !!existingWorkflowActiveVersionId && + (!mustUnpublishLocal || !unpublishedLocal); if (shouldPreserve) { importedWorkflow.active = !!existingWorkflowActiveVersionId; diff --git a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts index ca286fe0632..a5c400c90ed 100644 --- a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts @@ -148,6 +148,27 @@ describe('WorkflowRepository', () => { expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId); expect(updatedWorkflow?.active).toBe(true); }); + + it('should throw when workflow does not exist', async () => { + const workflowRepository = Container.get(WorkflowRepository); + + await expect(workflowRepository.publishVersion('non-existent-id')).rejects.toThrow( + 'Workflow "non-existent-id" not found.', + ); + }); + + it('should throw when workflow is archived', async () => { + const workflowRepository = Container.get(WorkflowRepository); + const workflow = await createWorkflowWithTriggerAndHistory({ isArchived: true }); + + await expect(workflowRepository.publishVersion(workflow.id)).rejects.toThrow( + 'Cannot publish archived Workflow', + ); + + const updatedWorkflow = await getWorkflowById(workflow.id); + expect(updatedWorkflow?.activeVersionId).toBeNull(); + expect(updatedWorkflow?.active).toBe(false); + }); }); describe('unpublishAll', () => { @@ -227,6 +248,19 @@ describe('WorkflowRepository', () => { }); }); + describe('getAllActiveIds', () => { + it('should exclude archived workflows', async () => { + const workflowRepository = Container.get(WorkflowRepository); + const activeNonArchived = await createActiveWorkflow(); + const activeArchived = await createActiveWorkflow(); + await workflowRepository.update(activeArchived.id, { isArchived: true }); + + const activeIds = await workflowRepository.getAllActiveIds(); + + expect(activeIds).toEqual([activeNonArchived.id]); + }); + }); + describe('getWorkflowsWithEvaluationCount', () => { it('should return 0 when no workflows have test runs', async () => { // diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index 4c6742b5646..ee90e5d053d 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -5,6 +5,7 @@ import { linkUserToProject, createWorkflowWithHistory, randomCredentialPayload, + setActiveVersion, testDb, mockInstance, } from '@n8n/backend-test-utils'; @@ -1932,5 +1933,37 @@ describe('SourceControlImportService', () => { expect(workflowInDb).toBeNull(); }); }); + + describe('archived workflows', () => { + it('should not preserve active state when existing workflow is archived', async () => { + const importingUser = await getGlobalOwner(); + const workflowId = nanoid(); + const versionId = nanoid(); + + await createWorkflowWithHistory( + { + id: workflowId, + name: 'Archived Workflow', + versionId, + active: true, + isArchived: true, + }, + importingUser, + ); + await setActiveVersion(workflowId, versionId); + + const imported = makeWorkflowImport({ id: workflowId, isArchived: false }); + const file = putWorkflowFile(workflowId, imported); + + await service.importWorkflowFromWorkFolder( + [mock({ id: workflowId, file })], + importingUser.id, + ); + + const post = await workflowRepository.findOne({ where: { id: workflowId } }); + expect(post?.active).toBe(false); + expect(post?.activeVersionId).toBeNull(); + }); + }); }); });