feat(core): Use active version instead of current version (no-changelog) (#21202)

This commit is contained in:
Daria 2025-11-20 17:47:24 +02:00 committed by GitHub
parent c7348970b3
commit ac91020bd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
126 changed files with 2538 additions and 398 deletions

View File

@ -89,6 +89,18 @@ export async function createManyWorkflows(
return await Promise.all(workflowRequests);
}
export async function createManyActiveWorkflows(
amount: number,
attributes: Partial<IWorkflowDb> = {},
userOrProject?: User | Project,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const workflowRequests = [...Array(amount)].map(
async (_) => await createActiveWorkflow(attributes, userOrProject),
);
return await Promise.all(workflowRequests);
}
export async function shareWorkflowWithUsers(workflow: IWorkflowBase, users: User[]) {
const sharedWorkflows: Array<DeepPartial<SharedWorkflow>> = await Promise.all(
users.map(async (user) => {
@ -135,7 +147,7 @@ export async function getWorkflowSharing(workflow: IWorkflowBase) {
*/
export async function createWorkflowWithTrigger(
attributes: Partial<IWorkflowDb> = {},
user?: User,
userOrProject?: User | Project,
) {
const workflow = await createWorkflow(
{
@ -170,7 +182,7 @@ export async function createWorkflowWithTrigger(
},
...attributes,
},
user,
userOrProject,
);
return workflow;
@ -201,12 +213,12 @@ export async function createWorkflowWithHistory(
*/
export async function createWorkflowWithTriggerAndHistory(
attributes: Partial<IWorkflowDb> = {},
user?: User,
userOrProject?: User | Project,
) {
const workflow = await createWorkflowWithTrigger(attributes, user);
const workflow = await createWorkflowWithTrigger(attributes, userOrProject);
// Create workflow history for the initial version
await createWorkflowHistory(workflow, user);
await createWorkflowHistory(workflow, userOrProject);
return workflow;
}
@ -227,12 +239,78 @@ export const getWorkflowById = async (id: string) =>
* @param workflow workflow to create history for
* @param user user who created the version (optional)
*/
export async function createWorkflowHistory(workflow: IWorkflowDb, user?: User): Promise<void> {
export async function createWorkflowHistory(
workflow: IWorkflowDb,
userOrProject?: User | Project,
): Promise<void> {
await Container.get(WorkflowHistoryRepository).insert({
workflowId: workflow.id,
versionId: workflow.versionId,
nodes: workflow.nodes,
connections: workflow.connections,
authors: user?.email ?? 'test@example.com',
authors: userOrProject instanceof User ? userOrProject.email : 'test@example.com',
});
}
/**
* Set the active version for a workflow
* @param workflowId workflow ID
* @param versionId version ID to set as active
*/
export async function setActiveVersion(workflowId: string, versionId: string): Promise<void> {
await Container.get(WorkflowRepository)
.createQueryBuilder()
.update()
.set({ activeVersionId: versionId })
.where('id = :workflowId', { workflowId })
.execute();
}
/**
* Create an active workflow with trigger, history, and activeVersionId set to the current version.
* This simulates a workflow that has been activated and is running.
* @param attributes workflow attributes
* @param user user to assign the workflow to
*/
export async function createActiveWorkflow(
attributes: Partial<IWorkflowDb> = {},
userOrProject?: User | Project,
) {
const workflow = await createWorkflowWithTriggerAndHistory(
{ active: true, ...attributes },
userOrProject,
);
await setActiveVersion(workflow.id, workflow.versionId);
workflow.activeVersionId = workflow.versionId;
return workflow;
}
/**
* Create a workflow with a specific active version.
* This simulates a workflow where the active version differs from the current version.
* @param activeVersionId the version ID to set as active
* @param attributes workflow attributes
* @param user user to assign the workflow to
*/
export async function createWorkflowWithActiveVersion(
activeVersionId: string,
attributes: Partial<IWorkflowDb> = {},
user?: User,
) {
const workflow = await createWorkflowWithTriggerAndHistory({ active: true, ...attributes }, user);
await Container.get(WorkflowHistoryRepository).insert({
workflowId: workflow.id,
versionId: activeVersionId,
nodes: workflow.nodes,
connections: workflow.connections,
authors: user?.email ?? 'test@example.com',
});
await setActiveVersion(workflow.id, activeVersionId);
workflow.activeVersionId = activeVersionId;
return workflow;
}

View File

@ -94,8 +94,44 @@ type EntityName =
*/
export async function truncate(entities: EntityName[]) {
const connection = Container.get(Connection);
const dbType = connection.options.type;
for (const name of entities) {
await connection.getRepository(name).delete({});
// Disable FK checks for MySQL/MariaDB to handle circular dependencies
if (dbType === 'mysql' || dbType === 'mariadb') {
await connection.query('SET FOREIGN_KEY_CHECKS=0');
}
try {
// Collect junction tables to clean
const junctionTablesToClean = new Set<string>();
// Find all junction tables associated with the entities being truncated
for (const name of entities) {
try {
const metadata = connection.getMetadata(name);
for (const relation of metadata.manyToManyRelations) {
if (relation.junctionEntityMetadata) {
const junctionTableName = relation.junctionEntityMetadata.tablePath;
junctionTablesToClean.add(junctionTableName);
}
}
} catch (error) {
// Skip
}
}
// Clean junction tables first (since they reference the entities)
for (const tableName of junctionTablesToClean) {
await connection.query(`DELETE FROM ${tableName}`);
}
for (const name of entities) {
await connection.getRepository(name).delete({});
}
} finally {
// Re-enable FK checks
if (dbType === 'mysql' || dbType === 'mariadb') {
await connection.query('SET FOREIGN_KEY_CHECKS=1');
}
}
}

View File

@ -26,6 +26,7 @@ import type { SharedWorkflow } from './shared-workflow';
import type { TagEntity } from './tag-entity';
import type { User } from './user';
import type { WorkflowEntity } from './workflow-entity';
import type { WorkflowHistory } from './workflow-history';
export type UsageCount = {
usageCount: number;
@ -79,6 +80,7 @@ export interface IWorkflowDb extends IWorkflowBase {
triggerCount: number;
tags?: TagEntity[];
parentFolder?: Folder | null;
activeVersion?: WorkflowHistory | null;
}
export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted {
@ -221,6 +223,7 @@ export namespace ListQueryDb {
| 'name'
| 'active'
| 'versionId'
| 'activeVersionId'
| 'createdAt'
| 'updatedAt'
| 'tags'

View File

@ -18,6 +18,7 @@ import type { SharedWorkflow } from './shared-workflow';
import type { TagEntity } from './tag-entity';
import type { TestRun } from './test-run.ee';
import type { ISimplifiedPinData, IWorkflowDb } from './types-db';
import type { WorkflowHistory } from './workflow-history';
import type { WorkflowStatistics } from './workflow-statistics';
import type { WorkflowTagMapping } from './workflow-tag-mapping';
import { objectRetriever, sqlite } from '../utils/transformers';
@ -103,6 +104,13 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl
@Column({ length: 36 })
versionId: string;
@Column({ name: 'activeVersionId', length: 36, nullable: true })
activeVersionId: string | null;
@ManyToOne('WorkflowHistory', { nullable: true })
@JoinColumn({ name: 'activeVersionId', referencedColumnName: 'versionId' })
activeVersion: WorkflowHistory | null;
@Column({ default: 1 })
versionCounter: number;

View File

@ -0,0 +1,43 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const WORKFLOWS_TABLE_NAME = 'workflow_entity';
const WORKFLOW_HISTORY_TABLE_NAME = 'workflow_history';
export class AddActiveVersionIdColumn1763047800000 implements ReversibleMigration {
async up({
schemaBuilder: { addColumns, column, addForeignKey },
queryRunner,
escape,
}: MigrationContext) {
const workflowsTableName = escape.tableName(WORKFLOWS_TABLE_NAME);
await addColumns(WORKFLOWS_TABLE_NAME, [column('activeVersionId').varchar(36)]);
await addForeignKey(
WORKFLOWS_TABLE_NAME,
'activeVersionId',
[WORKFLOW_HISTORY_TABLE_NAME, 'versionId'],
undefined,
'RESTRICT',
);
// For existing ACTIVE workflows, set activeVersionId = versionId
const versionIdColumn = escape.columnName('versionId');
const activeColumn = escape.columnName('active');
const activeVersionIdColumn = escape.columnName('activeVersionId');
await queryRunner.query(
`UPDATE ${workflowsTableName}
SET ${activeVersionIdColumn} = ${versionIdColumn}
WHERE ${activeColumn} = true`,
);
}
async down({ schemaBuilder: { dropColumns, dropForeignKey } }: MigrationContext) {
await dropForeignKey(WORKFLOWS_TABLE_NAME, 'activeVersionId', [
WORKFLOW_HISTORY_TABLE_NAME,
'versionId',
]);
await dropColumns(WORKFLOWS_TABLE_NAME, ['activeVersionId']);
}
}

View File

@ -1,5 +1,3 @@
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
import { AddWorkflowHistoryAutoSaveFields1762847206508 } from './../common/1762847206508-AddWorkflowHistoryAutoSaveFields';
import { InitialMigration1588157391238 } from './1588157391238-InitialMigration';
import { WebhookModel1592447867632 } from './1592447867632-WebhookModel';
import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt';
@ -55,6 +53,7 @@ import { AddToolsColumnToChatHubTables1761830340990 } from './1761830340990-AddT
import { CreateLdapEntities1674509946020 } from '../common/1674509946020-CreateLdapEntities';
import { PurgeInvalidWorkflowConnections1675940580449 } from '../common/1675940580449-PurgeInvalidWorkflowConnections';
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
import { AddMfaColumns1690000000030 } from '../common/1690000000040-AddMfaColumns';
import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex';
import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable';
import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-ExecutionSoftDelete';
@ -114,6 +113,8 @@ import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000
import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages';
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields';
import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-AddActiveVersionIdColumn';
import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar';
import type { Migration } from '../migration-types';
@ -235,4 +236,5 @@ export const mysqlMigrations: Migration[] = [
AddToolsColumnToChatHubTables1761830340990,
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
AddAttachmentsToChatHubMessages1761773155024,
AddActiveVersionIdColumn1763047800000,
];

View File

@ -114,6 +114,7 @@ import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields';
import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-AddActiveVersionIdColumn';
import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar';
import type { Migration } from '../migration-types';
@ -235,4 +236,5 @@ export const postgresMigrations: Migration[] = [
AddToolsColumnToChatHubTables1761830340990,
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
AddAttachmentsToChatHubMessages1761773155024,
AddActiveVersionIdColumn1763047800000,
];

View File

@ -110,6 +110,7 @@ import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields';
import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-AddActiveVersionIdColumn';
import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar';
import type { Migration } from '../migration-types';
@ -227,6 +228,7 @@ const sqliteMigrations: Migration[] = [
AddToolsColumnToChatHubTables1761830340990,
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
AddAttachmentsToChatHubMessages1761773155024,
AddActiveVersionIdColumn1763047800000,
];
export { sqliteMigrations };

View File

@ -249,9 +249,7 @@ describe('WorkflowRepository', () => {
}),
);
expect(queryBuilder.andWhere).toHaveBeenCalledWith('workflow.active = :active', {
active: true,
});
expect(queryBuilder.andWhere).toHaveBeenCalledWith('workflow.activeVersionId IS NOT NULL');
expect(queryBuilder.innerJoin).toHaveBeenCalledWith('workflow.shared', 'shared');
expect(queryBuilder.andWhere).toHaveBeenCalledWith('shared.projectId = :projectId', {

View File

@ -58,7 +58,7 @@ export class LicenseMetricsRepository extends Repository<LicenseMetrics> {
SELECT
(SELECT COUNT(*) FROM ${userTable} WHERE disabled = false) AS enabled_user_count,
(SELECT COUNT(*) FROM ${userTable}) AS total_user_count,
(SELECT COUNT(*) FROM ${workflowTable} WHERE active = true) AS active_workflow_count,
(SELECT COUNT(*) FROM ${workflowTable} WHERE ${this.toColumnName('activeVersionId')} IS NOT NULL) AS active_workflow_count,
(SELECT COUNT(*) FROM ${workflowTable}) AS total_workflow_count,
(SELECT COUNT(*) FROM ${credentialTable}) AS total_credentials_count,
(SELECT SUM(count) FROM ${workflowStatsTable} WHERE name IN ('production_success', 'production_error')) AS production_executions_count,

View File

@ -149,6 +149,7 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
where?: FindOptionsWhere<SharedWorkflow>;
includeTags?: boolean;
includeParentFolder?: boolean;
includeActiveVersion?: boolean;
em?: EntityManager;
} = {},
) {
@ -156,6 +157,7 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
where = {},
includeTags = false,
includeParentFolder = false,
includeActiveVersion = false,
em = this.manager,
} = options;
@ -169,6 +171,7 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
shared: { project: { projectRelations: { user: true } } },
tags: includeTags,
parentFolder: includeParentFolder,
activeVersion: includeActiveVersion,
},
},
});

View File

@ -14,9 +14,9 @@ export class WorkflowHistoryRepository extends Repository<WorkflowHistory> {
}
/**
* Delete workflow history records earlier than a given date, except for current workflow versions.
* Delete workflow history records earlier than a given date, except for current and active workflow versions.
*/
async deleteEarlierThanExceptCurrent(date: Date) {
async deleteEarlierThanExceptCurrentAndActive(date: Date) {
const currentVersionIdsSubquery = this.manager
.createQueryBuilder()
.subQuery()
@ -24,12 +24,21 @@ export class WorkflowHistoryRepository extends Repository<WorkflowHistory> {
.from(WorkflowEntity, 'w')
.getQuery();
const activeVersionIdsSubquery = this.manager
.createQueryBuilder()
.subQuery()
.select('w.activeVersionId')
.from(WorkflowEntity, 'w')
.where('w.activeVersionId IS NOT NULL')
.getQuery();
return await this.manager
.createQueryBuilder()
.delete()
.from(WorkflowHistory)
.where('createdAt < :date', { date })
.andWhere(`versionId NOT IN (${currentVersionIdsSubquery})`)
.andWhere(`versionId NOT IN (${activeVersionIdsSubquery})`)
.execute();
}
}

View File

@ -1,7 +1,14 @@
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm';
import {
DataSource,
IsNull,
MoreThanOrEqual,
Not,
QueryFailedError,
Repository,
} from '@n8n/typeorm';
import { WorkflowStatistics } from '../entities';
import type { User } from '../entities';
@ -125,7 +132,7 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
role: 'workflow:owner',
project: { projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } } },
},
active: true,
activeVersionId: Not(IsNull()),
},
name: StatisticsNames.productionSuccess,
count: MoreThanOrEqual(5),

View File

@ -1,6 +1,6 @@
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { DataSource, Repository, In, Like } from '@n8n/typeorm';
import { DataSource, Repository, In, Like, Not, IsNull } from '@n8n/typeorm';
import type {
SelectQueryBuilder,
UpdateResult,
@ -71,7 +71,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
async getAllActiveIds() {
const result = await this.find({
select: { id: true },
where: { active: true },
where: { activeVersionId: Not(IsNull()) },
relations: { shared: { project: { projectRelations: true } } },
});
@ -81,7 +81,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
async getActiveIds({ maxResults }: { maxResults?: number } = {}) {
const activeWorkflows = await this.find({
select: ['id'],
where: { active: true },
where: { activeVersionId: Not(IsNull()) },
// 'take' and 'order' are only needed when maxResults is provided:
...(maxResults ? { take: maxResults, order: { createdAt: 'ASC' } } : {}),
});
@ -90,14 +90,14 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
async getActiveCount() {
return await this.count({
where: { active: true },
where: { activeVersionId: Not(IsNull()) },
});
}
async findById(workflowId: string) {
return await this.findOne({
where: { id: workflowId },
relations: { shared: { project: { projectRelations: true } } },
relations: { shared: { project: { projectRelations: true } }, activeVersion: true },
});
}
@ -113,7 +113,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
async getActiveTriggerCount() {
const totalTriggerCount = await this.sum('triggerCount', {
active: true,
activeVersionId: Not(IsNull()),
});
return totalTriggerCount ?? 0;
}
@ -585,7 +585,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
filter: ListQuery.Options['filter'],
): void {
if (typeof filter?.active === 'boolean') {
qb.andWhere('workflow.active = :active', { active: filter.active });
if (filter.active) {
qb.andWhere('workflow.activeVersionId IS NOT NULL');
} else {
qb.andWhere('workflow.activeVersionId IS NULL');
}
}
}
@ -686,6 +690,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
'workflow.createdAt',
'workflow.updatedAt',
'workflow.versionId',
'workflow.activeVersionId',
'workflow.settings',
'workflow.description',
]);
@ -806,19 +811,42 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
}
async updateActiveState(workflowId: string, newState: boolean) {
return await this.update({ id: workflowId }, { active: newState });
if (newState) {
return await this.createQueryBuilder()
.update(WorkflowEntity)
.set({
activeVersionId: () => 'versionId',
active: true,
})
.where('id = :workflowId', { workflowId })
.execute();
} else {
return await this.update({ id: workflowId }, { active: false, activeVersionId: null });
}
}
async deactivateAll() {
return await this.update({ active: true }, { active: false });
return await this.update(
{ activeVersionId: Not(IsNull()) },
{ active: false, activeVersionId: null },
);
}
// We're planning to remove this command in V2, so for now set activeVersion to the current version
async activateAll() {
return await this.update({ active: false }, { active: true });
await this.manager
.createQueryBuilder()
.update(WorkflowEntity)
.set({
active: true,
activeVersionId: () => 'versionId',
})
.where('activeVersionId IS NULL')
.execute();
}
async findByActiveState(activeState: boolean) {
return await this.findBy({ active: activeState });
return await this.findBy({ activeVersionId: activeState ? Not(IsNull()) : IsNull() });
}
async moveAllToFolder(fromFolderId: string, toFolderId: string, tx: EntityManager) {
@ -854,12 +882,14 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
);
const workflows: Array<
Pick<WorkflowEntity, 'id' | 'name' | 'active'> & Partial<Pick<WorkflowEntity, 'nodes'>>
Pick<WorkflowEntity, 'id' | 'name' | 'active' | 'activeVersionId'> &
Partial<Pick<WorkflowEntity, 'nodes'>>
> = await qb
.select([
'workflow.id',
'workflow.name',
'workflow.active',
'workflow.activeVersionId',
...(includeNodes ? ['workflow.nodes'] : []),
])
.where(whereClause, parameters)

View File

@ -65,6 +65,7 @@ describe('ActiveExecutions', () => {
id: '123',
name: 'Test workflow 1',
active: false,
activeVersionId: null,
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),

View File

@ -1,5 +1,5 @@
import { mockLogger } from '@n8n/backend-test-utils';
import type { WorkflowEntity, WorkflowRepository } from '@n8n/db';
import type { WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import type {
@ -136,7 +136,9 @@ describe('ActiveWorkflowManager', () => {
activeWorkflowManager,
'addTriggersAndPollers',
);
workflowRepository.findById.mockResolvedValue(mock<WorkflowEntity>({ active: false }));
workflowRepository.findById.mockResolvedValue(
mock<WorkflowEntity>({ active: false, activeVersionId: null, activeVersion: null }),
);
const added = await activeWorkflowManager.add('some-id', mode);
@ -165,4 +167,76 @@ describe('ActiveWorkflowManager', () => {
expect(getAllActiveIds).toHaveBeenCalledTimes(1);
});
});
describe('activateWorkflow', () => {
beforeEach(() => {
// Set up as leader to allow workflow activation
Object.assign(instanceSettings, { isLeader: true });
});
test('should use active version when calling executeErrorWorkflow on activation failure', async () => {
// Create different nodes for draft vs active version
const draftNodes = [
{
id: 'draft-node-1',
name: 'Draft Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
},
];
const activeNodes = [
{
id: 'active-node-1',
name: 'Active Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
},
];
const activeVersion = mock<WorkflowHistory>({
versionId: 'v1',
workflowId: 'workflow-1',
nodes: activeNodes,
connections: {},
authors: 'test-user',
createdAt: new Date(),
updatedAt: new Date(),
});
const workflowEntity = mock<WorkflowEntity>({
id: 'workflow-1',
name: 'Test Workflow',
active: true,
activeVersionId: activeVersion.versionId,
nodes: draftNodes,
connections: {},
activeVersion,
});
workflowRepository.findById.mockResolvedValue(workflowEntity);
// Mock the add method to throw an error (simulating activation failure)
jest.spyOn(activeWorkflowManager, 'add').mockRejectedValue(new Error('Authorization failed'));
const executeErrorWorkflowSpy = jest
.spyOn(activeWorkflowManager, 'executeErrorWorkflow')
.mockImplementation(() => {});
await activeWorkflowManager['activateWorkflow']('workflow-1', 'init');
expect(executeErrorWorkflowSpy).toHaveBeenCalled();
// Get the workflow data that was passed to executeErrorWorkflow
const callArgs = executeErrorWorkflowSpy.mock.calls[0];
const workflowData = callArgs[1];
expect(workflowData.nodes).toEqual(activeNodes);
expect(workflowData.nodes[0].name).toBe('Active Webhook');
});
});
});

View File

@ -12,6 +12,7 @@ import type {
ExecuteWorkflowOptions,
IRun,
INodeExecutionData,
INode,
} from 'n8n-workflow';
import type PCancelable from 'p-cancelable';
@ -28,7 +29,12 @@ import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.ser
import { UrlService } from '@/services/url.service';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import { Telemetry } from '@/telemetry';
import { executeWorkflow, getBase, getRunData } from '@/workflow-execute-additional-data';
import {
executeWorkflow,
getBase,
getRunData,
getWorkflowData,
} from '@/workflow-execute-additional-data';
import * as WorkflowHelpers from '@/workflow-helpers';
const EXECUTION_ID = '123';
@ -130,7 +136,15 @@ describe('WorkflowExecuteAdditionalData', () => {
beforeEach(() => {
workflowRepository.get.mockResolvedValue(
mock<WorkflowEntity>({ id: EXECUTION_ID, nodes: [] }),
mock<WorkflowEntity>({
id: EXECUTION_ID,
name: 'Test Workflow',
active: false,
activeVersionId: null,
activeVersion: null,
nodes: [],
connections: {},
}),
);
activeExecutions.add.mockResolvedValue(EXECUTION_ID);
processRunExecutionData.mockReturnValue(getCancelablePromise(runWithData));
@ -279,6 +293,174 @@ describe('WorkflowExecuteAdditionalData', () => {
});
});
describe('getWorkflowData', () => {
beforeEach(() => {
workflowRepository.get.mockClear();
});
it('should load and use active version when workflow is active', async () => {
const activeVersionNodes: INode[] = [
mock<INode>({
id: 'active-node',
type: 'n8n-nodes-base.set',
name: 'Active Node',
typeVersion: 1,
parameters: {},
position: [250, 300],
}),
];
const activeVersionConnections = { 'Active Node': {} };
const currentNodes: INode[] = [
mock<INode>({
id: 'current-node',
type: 'n8n-nodes-base.set',
name: 'Current Node',
typeVersion: 1,
parameters: {},
position: [250, 300],
}),
];
const currentConnections = { 'Current Node': {} };
workflowRepository.get.mockResolvedValue(
mock<WorkflowEntity>({
id: 'workflow-123',
name: 'Test Workflow',
active: true,
activeVersionId: 'version-456',
nodes: currentNodes,
connections: currentConnections,
activeVersion: mock({
versionId: 'version-456',
workflowId: 'workflow-123',
nodes: activeVersionNodes,
connections: activeVersionConnections,
authors: 'user1',
createdAt: new Date(),
updatedAt: new Date(),
}),
}),
);
const result = await getWorkflowData({ id: 'workflow-123' }, 'parent-workflow-id');
expect(result.nodes).toEqual(activeVersionNodes);
expect(result.connections).toEqual(activeVersionConnections);
expect(workflowRepository.get).toHaveBeenCalledWith(
{ id: 'workflow-123' },
{ relations: ['activeVersion', 'tags'] },
);
});
it('should use current version when workflow has no active version', async () => {
const currentNodes: INode[] = [
mock<INode>({
id: 'current-node',
type: 'n8n-nodes-base.set',
name: 'Current Node',
typeVersion: 1,
parameters: {},
position: [250, 300],
}),
];
const currentConnections = { 'Current Node': {} };
workflowRepository.get.mockResolvedValue(
mock<WorkflowEntity>({
id: 'workflow-123',
name: 'Test Workflow',
active: false,
activeVersionId: null,
nodes: currentNodes,
connections: currentConnections,
activeVersion: null,
}),
);
const result = await getWorkflowData({ id: 'workflow-123' }, 'parent-workflow-id');
expect(result.nodes).toEqual(currentNodes);
expect(result.connections).toEqual(currentConnections);
});
it('should load activeVersion relation when tags are disabled', async () => {
const globalConfig = Container.get(GlobalConfig);
globalConfig.tags.disabled = true;
workflowRepository.get.mockResolvedValue(
mock<WorkflowEntity>({
id: 'workflow-123',
active: false,
activeVersionId: null,
nodes: [],
connections: {},
activeVersion: null,
}),
);
await getWorkflowData({ id: 'workflow-123' }, 'parent-workflow-id');
expect(workflowRepository.get).toHaveBeenCalledWith(
{ id: 'workflow-123' },
{ relations: ['activeVersion'] },
);
globalConfig.tags.disabled = false;
});
it('should throw error when workflow does not exist', async () => {
workflowRepository.get.mockResolvedValue(null);
await expect(getWorkflowData({ id: 'non-existent' }, 'parent-workflow-id')).rejects.toThrow(
'Workflow does not exist',
);
});
it('should use provided workflow code when id is not provided', async () => {
const workflowCode = mock<IWorkflowBase>({
id: 'code-workflow',
name: 'Code Workflow',
active: false,
nodes: [
mock<INode>({
id: 'node1',
type: 'n8n-nodes-base.set',
name: 'Node 1',
typeVersion: 1,
parameters: {},
position: [250, 300],
}),
],
connections: {},
});
const result = await getWorkflowData({ code: workflowCode }, 'parent-workflow-id');
expect(result).toEqual(workflowCode);
expect(workflowRepository.get).not.toHaveBeenCalled();
});
it('should set parent workflow settings when not provided in code', async () => {
const workflowCode = mock<IWorkflowBase>({
id: 'code-workflow',
name: 'Code Workflow',
active: false,
nodes: [],
connections: {},
settings: undefined,
});
const parentSettings = { executionOrder: 'v1' as const };
const result = await getWorkflowData(
{ code: workflowCode },
'parent-workflow-id',
parentSettings,
);
expect(result.settings).toEqual(parentSettings);
});
});
describe('getBase', () => {
const mockWebhookBaseUrl = 'webhook-base-url.com';
jest.spyOn(urlService, 'getWebhookBaseUrl').mockReturnValue(mockWebhookBaseUrl);

View File

@ -144,11 +144,11 @@ export class ActiveWorkflowManager {
*/
async isActive(workflowId: WorkflowId) {
const workflow = await this.workflowRepository.findOne({
select: ['active'],
select: ['activeVersionId'],
where: { id: workflowId },
});
return !!workflow?.active;
return !!workflow?.activeVersionId;
}
/**
@ -248,18 +248,27 @@ export class ActiveWorkflowManager {
async clearWebhooks(workflowId: WorkflowId) {
const workflowData = await this.workflowRepository.findOne({
where: { id: workflowId },
relations: { activeVersion: true },
});
if (workflowData === null) {
throw new UnexpectedError('Could not find workflow', { extra: { workflowId } });
}
if (!workflowData.activeVersion) {
throw new UnexpectedError('Active version not found for workflow', {
extra: { workflowId },
});
}
const { nodes, connections } = workflowData.activeVersion;
const workflow = new Workflow({
id: workflowId,
name: workflowData.name,
nodes: workflowData.nodes,
connections: workflowData.connections,
active: workflowData.active,
nodes,
connections,
active: true,
nodeTypes: this.nodeTypes,
staticData: workflowData.staticData,
settings: workflowData.settings,
@ -488,8 +497,17 @@ export class ActiveWorkflowManager {
},
);
if (!dbWorkflow.activeVersion) {
throw new UnexpectedError('Active version not found for workflow', {
extra: { workflowId: dbWorkflow.id },
});
}
const { nodes, connections } = dbWorkflow.activeVersion;
const workflowForError = { ...dbWorkflow, nodes, connections };
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.executeErrorWorkflow(error, dbWorkflow, 'internal');
this.executeErrorWorkflow(error, workflowForError, 'internal');
// do not keep trying to activate on authorization error
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
@ -568,7 +586,7 @@ export class ActiveWorkflowManager {
});
}
if (['init', 'leadershipChange'].includes(activationMode) && !dbWorkflow.active) {
if (['init', 'leadershipChange'].includes(activationMode) && !dbWorkflow.activeVersion) {
this.logger.debug(
`Skipping workflow ${formatWorkflow(dbWorkflow)} as it is no longer active`,
{ workflowId: dbWorkflow.id },
@ -577,12 +595,23 @@ export class ActiveWorkflowManager {
return added;
}
// Get workflow data from the active version
if (!dbWorkflow.activeVersion) {
throw new UnexpectedError('Active version not found for workflow', {
extra: { workflowId: dbWorkflow.id },
});
}
const { nodes, connections } = dbWorkflow.activeVersion;
dbWorkflow.nodes = nodes;
dbWorkflow.connections = connections;
workflow = new Workflow({
id: dbWorkflow.id,
name: dbWorkflow.name,
nodes: dbWorkflow.nodes,
connections: dbWorkflow.connections,
active: dbWorkflow.active,
nodes,
connections,
active: true,
nodeTypes: this.nodeTypes,
staticData: dbWorkflow.staticData,
settings: dbWorkflow.settings,
@ -683,7 +712,7 @@ export class ActiveWorkflowManager {
const error = ensureError(e);
const { message } = error;
await this.workflowRepository.update(workflowId, { active: false });
await this.workflowRepository.update(workflowId, { active: false, activeVersionId: null });
this.push.broadcast({
type: 'workflowFailedToActivate',

View File

@ -27,6 +27,8 @@ import type { ExportableFolder } from '../types/exportable-folders';
import type { ExportableProject } from '../types/exportable-project';
import { SourceControlContext } from '../types/source-control-context';
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
jest.mock('fast-glob');
const globalAdminContext = new SourceControlContext(
@ -50,11 +52,12 @@ describe('SourceControlImportService', () => {
const sourceControlScopedService = mock<SourceControlScopedService>();
const variableService = mock<VariablesService>();
const variablesRepository = mock<VariablesRepository>();
const activeWorkflowManager = mock<ActiveWorkflowManager>();
const service = new SourceControlImportService(
mockLogger,
mock(),
variableService,
mock(),
activeWorkflowManager,
mock(),
projectRepository,
mock(),
@ -259,6 +262,232 @@ describe('SourceControlImportService', () => {
expect.any(Object),
);
});
it('should set new workflows as inactive with null activeVersionId', async () => {
const mockUserId = 'user-id-123';
const mockWorkflowFile = '/mock/workflow1.json';
const mockWorkflowData = {
id: 'workflow1',
name: 'New Workflow',
nodes: [],
parentFolderId: null,
};
const candidates = [mock<SourceControlledFile>({ file: mockWorkflowFile, id: 'workflow1' })];
projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue(
Object.assign(new Project(), { id: 'project1', type: 'personal' }),
);
workflowRepository.findByIds.mockResolvedValue([]);
folderRepository.find.mockResolvedValue([]);
sharedWorkflowRepository.findWithFields.mockResolvedValue([]);
workflowRepository.upsert.mockResolvedValue({
identifiers: [{ id: 'workflow1' }],
generatedMaps: [],
raw: [],
});
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
await service.importWorkflowFromWorkFolder(candidates, mockUserId);
expect(workflowRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({
id: 'workflow1',
active: false,
activeVersionId: null,
}),
['id'],
);
expect(activeWorkflowManager.remove).not.toHaveBeenCalled();
expect(activeWorkflowManager.add).not.toHaveBeenCalled();
});
it('should keep existing inactive workflows inactive', async () => {
const mockUserId = 'user-id-123';
const mockWorkflowFile = '/mock/workflow1.json';
const mockWorkflowData = {
id: 'workflow1',
name: 'Existing Workflow',
nodes: [],
parentFolderId: null,
};
const candidates = [mock<SourceControlledFile>({ file: mockWorkflowFile, id: 'workflow1' })];
projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue(
Object.assign(new Project(), { id: 'project1', type: 'personal' }),
);
workflowRepository.findByIds.mockResolvedValue([
Object.assign(new WorkflowEntity(), {
id: 'workflow1',
name: 'Existing Workflow',
active: false,
activeVersionId: null,
}),
]);
folderRepository.find.mockResolvedValue([]);
sharedWorkflowRepository.findWithFields.mockResolvedValue([]);
workflowRepository.upsert.mockResolvedValue({
identifiers: [{ id: 'workflow1' }],
generatedMaps: [],
raw: [],
});
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
await service.importWorkflowFromWorkFolder(candidates, mockUserId);
expect(workflowRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({
id: 'workflow1',
active: false,
activeVersionId: null,
}),
['id'],
);
expect(activeWorkflowManager.remove).not.toHaveBeenCalled();
expect(activeWorkflowManager.add).not.toHaveBeenCalled();
});
it('should reactivate existing active workflows', async () => {
const mockUserId = 'user-id-123';
const mockWorkflowFile = '/mock/workflow1.json';
const mockWorkflowData = {
id: 'workflow1',
name: 'Active Workflow',
nodes: [],
parentFolderId: null,
};
const candidates = [mock<SourceControlledFile>({ file: mockWorkflowFile, id: 'workflow1' })];
projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue(
Object.assign(new Project(), { id: 'project1', type: 'personal' }),
);
workflowRepository.findByIds.mockResolvedValue([
Object.assign(new WorkflowEntity(), {
id: 'workflow1',
name: 'Active Workflow',
active: true,
activeVersionId: 'version-123',
}),
]);
folderRepository.find.mockResolvedValue([]);
sharedWorkflowRepository.findWithFields.mockResolvedValue([]);
workflowRepository.upsert.mockResolvedValue({
identifiers: [{ id: 'workflow1' }],
generatedMaps: [],
raw: [],
});
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
await service.importWorkflowFromWorkFolder(candidates, mockUserId);
expect(workflowRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({
id: 'workflow1',
active: true,
activeVersionId: 'version-123',
}),
['id'],
);
expect(activeWorkflowManager.remove).toHaveBeenCalledWith('workflow1');
expect(activeWorkflowManager.add).toHaveBeenCalledWith('workflow1', 'activate');
});
it('should deactivate archived workflows even if they were previously active', async () => {
const mockUserId = 'user-id-123';
const mockWorkflowFile = '/mock/workflow1.json';
const mockWorkflowData = {
id: 'workflow1',
name: 'Archived Workflow',
nodes: [],
parentFolderId: null,
isArchived: true,
};
const candidates = [mock<SourceControlledFile>({ file: mockWorkflowFile, id: 'workflow1' })];
projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue(
Object.assign(new Project(), { id: 'project1', type: 'personal' }),
);
workflowRepository.findByIds.mockResolvedValue([
Object.assign(new WorkflowEntity(), {
id: 'workflow1',
name: 'Archived Workflow',
active: true,
activeVersionId: 'version-123',
}),
]);
folderRepository.find.mockResolvedValue([]);
sharedWorkflowRepository.findWithFields.mockResolvedValue([]);
workflowRepository.upsert.mockResolvedValue({
identifiers: [{ id: 'workflow1' }],
generatedMaps: [],
raw: [],
});
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
await service.importWorkflowFromWorkFolder(candidates, mockUserId);
expect(workflowRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({
id: 'workflow1',
active: false,
activeVersionId: null,
}),
['id'],
);
expect(activeWorkflowManager.remove).toHaveBeenCalledWith('workflow1');
expect(activeWorkflowManager.add).not.toHaveBeenCalled();
});
it('should handle activation errors gracefully', async () => {
const mockUserId = 'user-id-123';
const mockWorkflowFile = '/mock/workflow1.json';
const mockWorkflowData = {
id: 'workflow1',
name: 'Workflow with activation error',
nodes: [],
parentFolderId: null,
};
const candidates = [mock<SourceControlledFile>({ file: mockWorkflowFile, id: 'workflow1' })];
projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue(
Object.assign(new Project(), { id: 'project1', type: 'personal' }),
);
workflowRepository.findByIds.mockResolvedValue([
Object.assign(new WorkflowEntity(), {
id: 'workflow1',
name: 'Workflow with activation error',
active: true,
activeVersionId: 'version-123',
}),
]);
folderRepository.find.mockResolvedValue([]);
sharedWorkflowRepository.findWithFields.mockResolvedValue([]);
workflowRepository.upsert.mockResolvedValue({
identifiers: [{ id: 'workflow1' }],
generatedMaps: [],
raw: [],
});
workflowRepository.update.mockResolvedValue({
generatedMaps: [],
raw: [],
affected: 1,
});
activeWorkflowManager.add.mockRejectedValue(new Error('Activation failed'));
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
const result = await service.importWorkflowFromWorkFolder(candidates, mockUserId);
expect(mockLogger.error).toHaveBeenCalledWith(
'Failed to activate workflow workflow1',
expect.any(Object),
);
expect(workflowRepository.update).toHaveBeenCalled();
expect(result).toEqual([{ id: 'workflow1', name: mockWorkflowFile }]);
});
});
describe('getRemoteCredentialsFromFiles', () => {

View File

@ -634,7 +634,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'],
fields: ['id', 'name', 'versionId', 'active', 'activeVersionId'],
});
const folders = await this.folderRepository.find({ select: ['id'] });
@ -662,9 +662,18 @@ export class SourceControlImportService {
// IWorkflowToImport having it typed as boolean. Imported workflows are always inactive if they are new,
// and existing workflows use the existing workflow's active status unless they have been archived on the remote.
// In that case, we deactivate the existing workflow on pull and turn it archived.
importedWorkflow.active = existingWorkflow
? existingWorkflow.active && !importedWorkflow.isArchived
: false;
if (existingWorkflow) {
if (importedWorkflow.isArchived) {
importedWorkflow.active = false;
importedWorkflow.activeVersionId = null;
} else {
importedWorkflow.active = !!existingWorkflow.activeVersionId;
importedWorkflow.activeVersionId = existingWorkflow.activeVersionId;
}
} else {
importedWorkflow.active = false;
importedWorkflow.activeVersionId = null;
}
const parentFolderId = importedWorkflow.parentFolderId ?? '';
@ -695,7 +704,7 @@ export class SourceControlImportService {
repository: this.sharedWorkflowRepository,
});
if (existingWorkflow?.active) {
if (existingWorkflow?.activeVersionId) {
await this.activateImportedWorkflow({ existingWorkflow, importedWorkflow });
}
@ -733,7 +742,7 @@ export class SourceControlImportService {
this.logger.debug(`Deactivating workflow id ${existingWorkflow.id}`);
await this.activeWorkflowManager.remove(existingWorkflow.id);
if (importedWorkflow.active) {
if (importedWorkflow.activeVersionId) {
// try activating the imported workflow
this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`);
await this.activeWorkflowManager.add(existingWorkflow.id, 'activate');

View File

@ -6,7 +6,7 @@ import { Service } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { DeleteResult } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import { In, IsNull, Not } from '@n8n/typeorm';
import EventEmitter from 'events';
import uniqby from 'lodash/uniqBy';
import type { MessageEventBusDestinationOptions } from 'n8n-workflow';
@ -165,7 +165,7 @@ export class MessageEventBus extends EventEmitter {
if (unfinishedExecutionIds.length > 0) {
const activeWorkflows = await this.workflowRepository.find({
where: { active: true },
where: { activeVersionId: Not(IsNull()) },
select: ['id', 'name'],
});
if (activeWorkflows.length > 0) {

View File

@ -172,6 +172,7 @@ describe('LogStreamingEventRelay', () => {
id: 'wf202',
name: 'Test Workflow',
active: true,
activeVersionId: 'some-version-id',
nodes: [],
connections: {},
staticData: undefined,
@ -608,6 +609,7 @@ describe('LogStreamingEventRelay', () => {
id: 'wf303',
name: 'Test Workflow with Nodes',
active: true,
activeVersionId: 'some-version-id',
nodes: [
{
id: 'node1',
@ -656,6 +658,7 @@ describe('LogStreamingEventRelay', () => {
id: 'wf404',
name: 'Test Workflow with Completed Node',
active: true,
activeVersionId: 'some-version-id',
nodes: [
{
id: 'node1',

View File

@ -1215,6 +1215,7 @@ describe('TelemetryEventRelay', () => {
id: 'workflow123',
name: 'Test Workflow',
active: true,
activeVersionId: 'some-version-id',
nodes: [
{
id: 'node1',

View File

@ -62,6 +62,7 @@ describe('Execution Lifecycle Hooks', () => {
id: workflowId,
name: 'Test Workflow',
active: true,
activeVersionId: 'some-version-id',
isArchived: false,
connections: {},
nodes: [

View File

@ -38,6 +38,7 @@ export function prepareExecutionDataForDbUpdate(parameters: {
'id',
'name',
'active',
'activeVersionId',
'isArchived',
'createdAt',
'updatedAt',

View File

@ -183,6 +183,7 @@ export class ExecutionService {
const executionMode = 'retry';
execution.workflowData.active = false;
execution.workflowData.activeVersionId = null;
// Start the workflow
const data: IWorkflowExecutionDataProcess = {

View File

@ -0,0 +1,15 @@
import type { IWorkflowBase } from 'n8n-workflow';
/**
* Determines the active status of a workflow from workflow data.
*
* This function handles backward compatibility:
* - Newer workflow data uses `activeVersionId` (string = active, null/undefined = inactive)
* - Older workflow data (before activeVersionId was introduced) falls back to the `active` boolean field
*
* @param workflowData - Workflow data
* @returns true if the workflow should be considered active, false otherwise
*/
export function getWorkflowActiveStatusFromWorkflowData(workflowData: IWorkflowBase): boolean {
return !!workflowData.activeVersionId || workflowData.active;
}

View File

@ -6,6 +6,7 @@ export class WorkflowSelect extends BaseSelect {
'id', // always included downstream
'name',
'active',
'activeVersionId',
'tags',
'createdAt',
'updatedAt',

View File

@ -6,6 +6,8 @@ export const createWorkflow = (id: string, name: string, nodes: INode[], active
id,
name,
active,
activeVersionId: active ? 'v1' : null,
versionId: 'v1',
nodes,
statistics: [
{

View File

@ -87,7 +87,7 @@ export class BreakingChangeService {
// Process workflows in batches
for (let skip = 0; skip < totalWorkflows; skip += this.batchSize) {
const workflows = await this.workflowRepository.find({
select: ['id', 'name', 'active', 'nodes', 'updatedAt', 'statistics'],
select: ['id', 'name', 'active', 'activeVersionId', 'nodes', 'updatedAt', 'statistics'],
skip,
take: this.batchSize,
order: { id: 'ASC' },
@ -115,7 +115,7 @@ export class BreakingChangeService {
const affectedWorkflow: BreakingChangeAffectedWorkflow = {
id: workflow.id,
name: workflow.name,
active: workflow.active,
active: !!workflow.activeVersionId,
issues: workflowDetectionResult.issues,
numberOfExecutions: workflow.statistics.reduce(
(acc, cur) => acc + (cur.count || 0),

View File

@ -80,6 +80,7 @@ export class ChatHubWorkflowService {
newWorkflow.versionId = uuidv4();
newWorkflow.name = `Chat ${sessionId}`;
newWorkflow.active = false;
newWorkflow.activeVersionId = null;
newWorkflow.nodes = nodes;
newWorkflow.connections = connections;
newWorkflow.settings = {
@ -129,6 +130,7 @@ export class ChatHubWorkflowService {
newWorkflow.versionId = uuidv4();
newWorkflow.name = `Chat ${sessionId} (Title Generation)`;
newWorkflow.active = false;
newWorkflow.activeVersionId = null;
newWorkflow.nodes = nodes;
newWorkflow.connections = connections;
newWorkflow.settings = {

View File

@ -605,7 +605,7 @@ export class ChatHubService {
models: workflows
// Ensure the user has at least read access to the workflow
.filter((workflow) => workflow.scopes.includes('workflow:read'))
.filter((workflow) => workflow.active)
.filter((workflow) => !!workflow.activeVersionId)
.flatMap((workflow) => {
const chatTrigger = workflow.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
if (!chatTrigger) {

View File

@ -203,6 +203,7 @@ describe('McpSettingsController', () => {
const entity = new WorkflowEntity();
entity.id = workflowId;
entity.active = true;
entity.activeVersionId = overrides.active === false ? null : 'current-version-id';
entity.nodes = [createWebhookNode()];
entity.settings = { saveManualExecutions: true };
entity.versionId = 'current-version-id';

View File

@ -27,6 +27,8 @@ export const createWorkflow = (overrides: Partial<WorkflowEntity> = {}) => ({
},
],
active: overrides.active ?? false,
versionId: 'some-version-id',
activeVersionId: overrides.active ? 'some-version-id' : null,
isArchived: overrides.isArchived ?? false,
createdAt: overrides.createdAt ?? new Date('2024-01-01T00:00:00.000Z'),
updatedAt: overrides.updatedAt ?? new Date('2024-01-02T00:00:00.000Z'),

View File

@ -73,6 +73,7 @@ describe('search-workflows MCP tool', () => {
id: 'a',
name: 'Alpha',
active: false,
activeVersionId: null,
createdAt: new Date('2024-01-01T00:00:00.000Z').toISOString(),
updatedAt: new Date('2024-01-02T00:00:00.000Z').toISOString(),
triggerCount: 1,
@ -82,6 +83,7 @@ describe('search-workflows MCP tool', () => {
id: 'b',
name: 'Beta',
active: true,
activeVersionId: workflows[1].versionId,
createdAt: new Date('2024-01-01T00:00:00.000Z').toISOString(),
updatedAt: new Date('2024-01-02T00:00:00.000Z').toISOString(),
triggerCount: 1,

View File

@ -88,7 +88,7 @@ export class McpSettingsController {
);
}
if (!workflow.active && dto.availableInMCP) {
if (!workflow.activeVersionId && dto.availableInMCP) {
throw new BadRequestError('MCP access can only be set for active workflows');
}

View File

@ -126,7 +126,7 @@ export async function getWorkflowDetails(
const sanitizedWorkflow: WorkflowDetailsResult['workflow'] = {
id: workflow.id,
name: workflow.name,
active: workflow.active,
active: workflow.activeVersionId !== null,
isArchived: workflow.isArchived,
versionId: workflow.versionId,
triggerCount: workflow.triggerCount,

View File

@ -165,11 +165,22 @@ export async function searchWorkflows(
);
const formattedWorkflows: SearchWorkflowsItem[] = (workflows as WorkflowEntity[]).map(
({ id, name, description, active, createdAt, updatedAt, triggerCount, nodes }) => ({
({
id,
name,
description,
active,
activeVersionId,
createdAt,
updatedAt,
triggerCount,
nodes,
}) => ({
id,
name,
description,
active,
activeVersionId,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
triggerCount,

View File

@ -44,6 +44,7 @@ describe('WorkflowIndexService', () => {
id: 'workflow-123',
name: 'Test Workflow',
active: true,
activeVersionId: 'some-version-id',
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),

View File

@ -82,7 +82,7 @@ export declare namespace WorkflowRequest {
type Get = AuthenticatedRequest<{ id: string }, {}, {}, { excludePinnedData?: boolean }>;
type Delete = Get;
type Update = AuthenticatedRequest<{ id: string }, {}, WorkflowEntity, {}>;
type Activate = Get;
type Activate = AuthenticatedRequest<{ id: string }, {}, {}, {}>;
type GetTags = Get;
type UpdateTags = AuthenticatedRequest<{ id: string }, {}, TagEntity[]>;
type Transfer = AuthenticatedRequest<{ id: string }, {}, { destinationProjectId: string }>;

View File

@ -0,0 +1,37 @@
type: object
readOnly: true
nullable: true
additionalProperties: false
properties:
versionId:
type: string
readOnly: true
description: Unique identifier for this workflow version
example: 7c6b9e3f-8d4a-4b2c-9f1e-6a5d3b8c7e4f
workflowId:
type: string
readOnly: true
description: The workflow this version belongs to
example: 2tUt1wbLX592XDdX
nodes:
type: array
readOnly: true
items:
$ref: './node.yml'
connections:
type: object
readOnly: true
example: { Jira: { main: [[{ node: 'Jira', type: 'main', index: 0 }]] } }
authors:
type: string
readOnly: true
description: Comma-separated list of author IDs who contributed to this version
example: 1,2,3
createdAt:
type: string
format: date-time
readOnly: true
updatedAt:
type: string
format: date-time
readOnly: true

View File

@ -50,3 +50,5 @@ properties:
type: array
items:
$ref: './sharedWorkflow.yml'
activeVersion:
$ref: './activeVersion.yml'

View File

@ -2,7 +2,7 @@ import { GlobalConfig } from '@n8n/config';
import { WorkflowEntity, ProjectRepository, TagRepository, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, Like, QueryFailedError } from '@n8n/typeorm';
import { In, IsNull, Like, Not, QueryFailedError } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { FindOptionsWhere } from '@n8n/typeorm';
import type express from 'express';
@ -12,7 +12,11 @@ import { z } from 'zod';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import { addNodeIds, replaceInvalidCredentials } from '@/workflow-helpers';
import {
addNodeIds,
getActiveVersionUpdateValue,
replaceInvalidCredentials,
} from '@/workflow-helpers';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import { WorkflowService } from '@/workflows/workflow.service';
@ -116,7 +120,10 @@ export = {
id,
req.user,
['workflow:read'],
{ includeTags: !Container.get(GlobalConfig).tags.disabled },
{
includeTags: !Container.get(GlobalConfig).tags.disabled,
includeActiveVersion: true,
},
);
if (!workflow) {
@ -153,10 +160,18 @@ export = {
} = req.query;
const where: FindOptionsWhere<WorkflowEntity> = {
...(active !== undefined && { active }),
...(name !== undefined && { name: Like('%' + name.trim() + '%') }),
};
// Filter by active status based on activeVersionId
if (active !== undefined) {
if (active) {
where.activeVersionId = Not(IsNull());
} else {
where.activeVersionId = IsNull();
}
}
if (['global:owner', 'global:admin'].includes(req.user.role.slug)) {
if (tags) {
const workflowIds = await Container.get(TagRepository).getWorkflowIdsViaTags(
@ -211,10 +226,11 @@ export = {
where.id = In(workflowsIds);
}
const selectFields: (keyof WorkflowEntity)[] = [
const selectFields: Array<keyof WorkflowEntity> = [
'id',
'name',
'active',
'activeVersionId',
'createdAt',
'updatedAt',
'isArchived',
@ -232,7 +248,7 @@ export = {
selectFields.push('pinData');
}
const relations = ['shared'];
const relations = ['shared', 'activeVersion'];
if (!Container.get(GlobalConfig).tags.disabled) {
relations.push('tags');
}
@ -279,6 +295,7 @@ export = {
id,
req.user,
['workflow:update'],
{ includeActiveVersion: true },
);
if (!workflow) {
@ -292,13 +309,28 @@ export = {
const workflowManager = Container.get(ActiveWorkflowManager);
if (workflow.active) {
if (workflow.activeVersionId !== null) {
// When workflow gets saved always remove it as the triggers could have been
// changed and so the changes would not take effect
await workflowManager.remove(id);
}
try {
// First add a record to workflow history to be able to get the full version object during the update
await Container.get(WorkflowHistoryService).saveVersion(req.user, updateData, workflow.id);
const updatedVersion = await Container.get(WorkflowHistoryService).getVersion(
req.user,
id,
updateData.versionId,
);
updateData.activeVersion = getActiveVersionUpdateValue(
workflow,
updatedVersion,
undefined, // active is read-only
);
await updateWorkflow(workflow, updateData);
} catch (error) {
if (error instanceof Error) {
@ -306,7 +338,7 @@ export = {
}
}
if (workflow.active) {
if (workflow.activeVersionId !== null) {
try {
await workflowManager.add(workflow.id, 'update');
} catch (error) {
@ -318,14 +350,6 @@ export = {
const updatedWorkflow = await getWorkflowById(workflow.id);
if (updatedWorkflow) {
await Container.get(WorkflowHistoryService).saveVersion(
req.user,
updatedWorkflow,
workflow.id,
);
}
await Container.get(ExternalHooks).run('workflow.afterUpdate', [updateData]);
Container.get(EventService).emit('workflow-saved', {
user: req.user,
@ -346,6 +370,7 @@ export = {
id,
req.user,
['workflow:update'],
{ includeActiveVersion: true },
);
if (!workflow) {
@ -354,20 +379,35 @@ export = {
return res.status(404).json({ message: 'Not Found' });
}
if (!workflow.active) {
const activeVersionId = workflow.versionId;
const newVersionIsBeingActivated =
activeVersionId && activeVersionId !== workflow.activeVersion?.versionId;
if (!workflow.activeVersionId || newVersionIsBeingActivated) {
try {
// change the status to active in the DB
const activeVersion = await setWorkflowAsActive(req.user, workflow.id, activeVersionId);
await Container.get(ActiveWorkflowManager).add(workflow.id, 'activate');
// Update the workflow object for response
workflow.active = true;
workflow.activeVersionId = activeVersionId;
workflow.activeVersion = activeVersion;
} catch (error) {
// Rollback: restore previous state
await Container.get(WorkflowRepository).update(workflow.id, {
active: workflow.active,
activeVersion: workflow.activeVersion,
updatedAt: new Date(),
});
if (error instanceof Error) {
return res.status(400).json({ message: error.message });
}
}
// change the status to active in the DB
await setWorkflowAsActive(workflow.id);
workflow.active = true;
Container.get(EventService).emit('workflow-activated', {
user: req.user,
workflowId: workflow.id,
@ -378,7 +418,7 @@ export = {
return res.json(workflow);
}
// nothing to do as the workflow is already active
// nothing to do as this version is already active
return res.json(workflow);
},
],
@ -402,12 +442,15 @@ export = {
const activeWorkflowManager = Container.get(ActiveWorkflowManager);
if (workflow.active) {
if (workflow.activeVersionId) {
await activeWorkflowManager.remove(workflow.id);
await setWorkflowAsInactive(workflow.id);
// Update the workflow object for response
workflow.active = false;
workflow.activeVersionId = null;
workflow.activeVersion = null;
Container.get(EventService).emit('workflow-deactivated', {
user: req.user,

View File

@ -13,6 +13,7 @@ import { PROJECT_OWNER_ROLE_SLUG, type Scope, type WorkflowSharingRole } from '@
import type { WorkflowId } from 'n8n-workflow';
import { License } from '@/license';
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
function insertIf(condition: boolean, elements: string[]): string[] {
@ -85,16 +86,26 @@ export async function createWorkflow(
});
}
export async function setWorkflowAsActive(workflowId: WorkflowId) {
export async function setWorkflowAsActive(user: User, workflowId: WorkflowId, versionId: string) {
const activeVersion = await Container.get(WorkflowHistoryService).getVersion(
user,
workflowId,
versionId,
);
await Container.get(WorkflowRepository).update(workflowId, {
active: true,
activeVersion,
updatedAt: new Date(),
});
return activeVersion;
}
export async function setWorkflowAsInactive(workflowId: WorkflowId) {
return await Container.get(WorkflowRepository).update(workflowId, {
active: false,
activeVersion: null,
updatedAt: new Date(),
});
}

View File

@ -26,6 +26,7 @@ import type {
import { EventService } from '@/events/event.service';
import { getLifecycleHooksForScalingWorker } from '@/execution-lifecycle/execution-lifecycle-hooks';
import { getWorkflowActiveStatusFromWorkflowData } from '@/executions/execution.utils';
import { ManualExecutionService } from '@/manual-execution.service';
import { NodeTypes } from '@/node-types';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
@ -114,7 +115,7 @@ export class JobProcessor {
name: execution.workflowData.name,
nodes: execution.workflowData.nodes,
connections: execution.workflowData.connections,
active: execution.workflowData.active,
active: getWorkflowActiveStatusFromWorkflowData(execution.workflowData),
nodeTypes: this.nodeTypes,
staticData,
settings: execution.workflowData.settings,

View File

@ -19,7 +19,7 @@ export class CredentialsRiskReporter implements RiskReporter {
const days = this.securityConfig.daysAbandonedWorkflow;
const allExistingCreds = await this.getAllExistingCreds();
const { credsInAnyUse, credsInActiveUse } = await this.getAllCredsInUse(workflows);
const { credsInAnyUse, credsInActiveUse } = this.getAllCredsInUse(workflows);
const recentlyExecutedCreds = await this.getCredsInRecentlyExecutedWorkflows(days);
const credsNotInAnyUse = allExistingCreds.filter((c) => !credsInAnyUse.has(c.id));
@ -81,7 +81,7 @@ export class CredentialsRiskReporter implements RiskReporter {
return report;
}
private async getAllCredsInUse(workflows: IWorkflowBase[]) {
private getAllCredsInUse(workflows: IWorkflowBase[]) {
const credsInAnyUse = new Set<string>();
const credsInActiveUse = new Set<string>();
@ -94,7 +94,9 @@ export class CredentialsRiskReporter implements RiskReporter {
credsInAnyUse.add(cred.id);
if (workflow.active) credsInActiveUse.add(cred.id);
if (workflow.activeVersionId !== null) {
credsInActiveUse.add(cred.id);
}
});
});
});

View File

@ -130,7 +130,7 @@ export class InstanceRiskReporter implements RiskReporter {
private getUnprotectedWebhookNodes(workflows: IWorkflowBase[]) {
return workflows.reduce<Risk.NodeLocation[]>((acc, workflow) => {
if (!workflow.active) return acc;
if (!workflow.activeVersionId) return acc;
workflow.nodes.forEach((node) => {
if (

View File

@ -30,7 +30,7 @@ export class SecurityAuditService {
}
const workflows = await this.workflowRepository.find({
select: ['id', 'name', 'active', 'nodes', 'connections'],
select: ['id', 'name', 'active', 'activeVersionId', 'nodes', 'connections'],
});
const promises = categories.map(async (c) => await this.reporters[c].report(workflows));

View File

@ -6,6 +6,7 @@ import type {
WorkflowRepository,
UserRepository,
} from '@n8n/db';
import { IsNull, Not } from '@n8n/typeorm';
import RudderStack from '@rudderstack/rudder-sdk-node';
import type { Response } from 'express';
import { mock } from 'jest-mock-extended';
@ -100,7 +101,7 @@ describe('HooksService', () => {
it('hooksService.workflowsCount should call workflowRepository.count', async () => {
// ARRANGE
const filter = { where: { active: true } };
const filter = { where: { activeVersionId: Not(IsNull()) } };
// ACT
await hooksService.workflowsCount(filter);

View File

@ -87,8 +87,9 @@ export class ImportService {
const { manager: dbManager } = this.credentialsRepository;
await dbManager.transaction(async (tx) => {
for (const workflow of workflows) {
if (workflow.active) {
if (workflow.active || workflow.activeVersionId) {
workflow.active = false;
workflow.activeVersionId = null;
this.logger.info(`Deactivating workflow "${workflow.name}". Remember to activate later.`);
}

View File

@ -0,0 +1,135 @@
import { mockLogger } from '@n8n/backend-test-utils';
import type { WebhookEntity, WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db';
import type { Response } from 'express';
import { mock } from 'jest-mock-extended';
import type {
IHttpRequestMethods,
INode,
INodeType,
IWebhookData,
IWorkflowExecuteAdditionalData,
Workflow,
} from 'n8n-workflow';
import type { NodeTypes } from '@/node-types';
import { LiveWebhooks } from '@/webhooks/live-webhooks';
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
import type { WebhookService } from '@/webhooks/webhook.service';
import type { WebhookRequest } from '@/webhooks/webhook.types';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import type { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
jest.mock('@/webhooks/webhook-helpers');
jest.mock('@/workflow-execute-additional-data');
describe('LiveWebhooks', () => {
const workflowRepository = mock<WorkflowRepository>();
const webhookService = mock<WebhookService>();
const nodeTypes = mock<NodeTypes>();
const workflowStaticDataService = mock<WorkflowStaticDataService>();
let liveWebhooks: LiveWebhooks;
beforeEach(() => {
jest.clearAllMocks();
liveWebhooks = new LiveWebhooks(
mockLogger(),
nodeTypes,
webhookService,
workflowRepository,
workflowStaticDataService,
);
// Mock WorkflowExecuteAdditionalData.getBase to avoid DI issues
(WorkflowExecuteAdditionalData.getBase as jest.Mock).mockResolvedValue(
mock<IWorkflowExecuteAdditionalData>(),
);
});
describe('executeWebhook', () => {
it('should use active version nodes when executing webhook', async () => {
const workflowId = 'workflow-1';
const nodeName = 'Webhook';
const webhookPath = 'test-webhook';
const httpMethod: IHttpRequestMethods = 'GET';
const createWebhookNode = (id: string, position: [number, number]): INode => ({
id,
name: nodeName,
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position,
parameters: { path: webhookPath, httpMethod },
});
const draftNodes = [createWebhookNode('webhook-node-draft', [0, 0])];
const activeNodes = [createWebhookNode('webhook-node-active', [100, 200])];
const activeVersion = mock<WorkflowHistory>({
versionId: 'v1',
workflowId,
nodes: activeNodes,
connections: {},
authors: 'test-user',
createdAt: new Date(),
updatedAt: new Date(),
});
const workflowEntity = mock<WorkflowEntity>({
id: workflowId,
name: 'Test Workflow',
active: true,
activeVersionId: activeVersion.versionId,
nodes: draftNodes,
connections: {},
activeVersion,
shared: [{ role: 'workflow:owner', project: { id: 'project-1', projectRelations: [] } }],
});
const webhookEntity = mock<WebhookEntity>({
workflowId,
node: nodeName,
webhookPath,
method: httpMethod,
isDynamic: false,
});
const webhookNodeType = mock<INodeType>({
description: { name: nodeName, properties: [] },
webhook: jest.fn(),
});
const webhookData = mock<IWebhookData>({
httpMethod,
path: webhookPath,
node: nodeName,
webhookDescription: {},
workflowId,
});
webhookService.findWebhook.mockResolvedValue(webhookEntity);
webhookService.getWebhookMethods.mockResolvedValue([httpMethod]);
workflowRepository.findOne.mockResolvedValue(workflowEntity);
nodeTypes.getByNameAndVersion.mockReturnValue(webhookNodeType);
webhookService.getNodeWebhooks.mockReturnValue([webhookData]);
let capturedNodes: INode[] = [];
(WebhookHelpers.executeWebhook as jest.Mock).mockImplementation(
(workflow: Workflow, ...args: unknown[]) => {
capturedNodes = Object.values(workflow.nodes);
const webhookCallback = args[args.length - 1] as (
error: Error | null,
data: object,
) => void;
void webhookCallback(null, {});
},
);
const request = mock<WebhookRequest>({ method: httpMethod, params: { path: webhookPath } });
await liveWebhooks.executeWebhook(request, mock<Response>());
expect(capturedNodes[0].id).toBe('webhook-node-active');
});
});
});

View File

@ -3,13 +3,19 @@ import type express from 'express';
import { mock } from 'jest-mock-extended';
import { FORM_NODE_TYPE, WAITING_FORMS_EXECUTION_STATUS, type Workflow } from 'n8n-workflow';
import type { WaitingWebhookRequest } from '../webhook.types';
import { WaitingForms } from '@/webhooks/waiting-forms';
import type { WaitingWebhookRequest } from '../webhook.types';
class TestWaitingForms extends WaitingForms {
exposeGetWorkflow(execution: IExecutionResponse): Workflow {
return this.getWorkflow(execution);
}
}
describe('WaitingForms', () => {
const executionRepository = mock<ExecutionRepository>();
const waitingForms = new WaitingForms(mock(), mock(), executionRepository, mock(), mock());
const waitingForms = new TestWaitingForms(mock(), mock(), executionRepository, mock(), mock());
beforeEach(() => {
jest.restoreAllMocks();
@ -220,5 +226,93 @@ describe('WaitingForms', () => {
expect(result).toEqual({ noWebhookResponse: true });
expect(res.send).toHaveBeenCalledWith(execution.status);
});
it('should handle old executions with missing activeVersionId field when active=true', () => {
const execution = mock<IExecutionResponse>({
workflowData: {
id: 'workflow1',
name: 'Test Workflow',
nodes: [],
connections: {},
active: true,
activeVersionId: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted
settings: {},
staticData: {},
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
},
});
const workflow = waitingForms.exposeGetWorkflow(execution);
expect(workflow.active).toBe(true);
});
it('should handle old executions with missing activeVersionId field when active=false', () => {
const execution = mock<IExecutionResponse>({
workflowData: {
id: 'workflow1',
name: 'Test Workflow',
nodes: [],
connections: {},
active: false,
activeVersionId: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted
settings: {},
staticData: {},
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
},
});
const workflow = waitingForms.exposeGetWorkflow(execution);
expect(workflow.active).toBe(false);
});
it('should set active to true when activeVersionId exists', () => {
const execution = mock<IExecutionResponse>({
workflowData: {
id: 'workflow1',
name: 'Test Workflow',
nodes: [],
connections: {},
active: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted
activeVersionId: 'version-123',
settings: {},
staticData: {},
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
},
});
const workflow = waitingForms.exposeGetWorkflow(execution);
expect(workflow.active).toBe(true);
});
it('should set active to false when activeVersionId is null', () => {
const execution = mock<IExecutionResponse>({
workflowData: {
id: 'workflow1',
name: 'Test Workflow',
nodes: [],
connections: {},
active: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted
activeVersionId: null,
settings: {},
staticData: {},
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
},
});
const workflow = waitingForms.exposeGetWorkflow(execution);
expect(workflow.active).toBe(false);
});
});
});

View File

@ -3,19 +3,26 @@ import type express from 'express';
import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import { generateUrlSignature, prepareUrlForSigning, WAITING_TOKEN_QUERY_PARAM } from 'n8n-core';
import type { IWorkflowBase, Workflow } from 'n8n-workflow';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { WaitingWebhooks } from '@/webhooks/waiting-webhooks';
import type { WaitingWebhookRequest } from '@/webhooks/webhook.types';
class TestWaitingWebhooks extends WaitingWebhooks {
exposeCreateWorkflow(workflowData: IWorkflowBase): Workflow {
return this.createWorkflow(workflowData);
}
}
describe('WaitingWebhooks', () => {
const SIGNING_SECRET = 'test-secret';
const executionRepository = mock<ExecutionRepository>();
const mockInstanceSettings = mock<InstanceSettings>({
hmacSignatureSecret: SIGNING_SECRET,
});
const waitingWebhooks = new WaitingWebhooks(
const waitingWebhooks = new TestWaitingWebhooks(
mock(),
mock(),
executionRepository,
@ -197,4 +204,88 @@ describe('WaitingWebhooks', () => {
expect(result).toBe(false);
});
});
describe('createWorkflow', () => {
it('should handle old executions with missing activeVersionId field when active=true', () => {
const workflowData = {
id: 'workflow1',
name: 'Test Workflow',
nodes: [],
connections: {},
active: true,
activeVersionId: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted
settings: {},
staticData: {},
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
};
// @ts-expect-error: createWorkflow typing is incorrect, will be fixed later
const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData);
expect(workflow.active).toBe(true);
});
it('should handle old executions with missing activeVersionId field when active=false', () => {
const workflowData = {
id: 'workflow1',
name: 'Test Workflow',
nodes: [],
connections: {},
active: false,
activeVersionId: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted
settings: {},
staticData: {},
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
};
// @ts-expect-error: createWorkflow typing is incorrect, will be fixed later
const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData);
expect(workflow.active).toBe(false);
});
it('should set active to true when activeVersionId exists', () => {
const workflowData: IWorkflowBase = {
id: 'workflow1',
name: 'Test Workflow',
nodes: [],
connections: {},
active: true,
activeVersionId: 'version-123',
settings: {},
staticData: {},
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
};
const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData);
expect(workflow.active).toBe(true);
});
it('should set active to false when activeVersionId is null', () => {
const workflowData: IWorkflowBase = {
id: 'workflow1',
name: 'Test Workflow',
nodes: [],
connections: {},
active: false,
activeVersionId: null,
settings: {},
staticData: {},
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
};
const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData);
expect(workflow.active).toBe(false);
});
});
});

View File

@ -96,19 +96,30 @@ export class LiveWebhooks implements IWebhookManager {
const workflowData = await this.workflowRepository.findOne({
where: { id: webhook.workflowId },
relations: { shared: { project: { projectRelations: true } } },
relations: {
activeVersion: true,
shared: { project: { projectRelations: true } },
},
});
if (workflowData === null) {
throw new NotFoundError(`Could not find workflow with id "${webhook.workflowId}"`);
}
if (!workflowData.activeVersion) {
throw new NotFoundError(
`Active version not found for workflow with id "${webhook.workflowId}"`,
);
}
const { nodes, connections } = workflowData.activeVersion;
const workflow = new Workflow({
id: webhook.workflowId,
name: workflowData.name,
nodes: workflowData.nodes,
connections: workflowData.connections,
active: workflowData.active,
nodes,
connections,
active: workflowData.activeVersionId !== null,
nodeTypes: this.nodeTypes,
staticData: workflowData.staticData,
settings: workflowData.settings,

View File

@ -10,6 +10,7 @@ import {
} from 'n8n-workflow';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { getWorkflowActiveStatusFromWorkflowData } from '@/executions/execution.utils';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { WaitingWebhooks } from '@/webhooks/waiting-webhooks';
@ -30,14 +31,14 @@ export class WaitingForms extends WaitingWebhooks {
}
}
private getWorkflow(execution: IExecutionResponse) {
protected getWorkflow(execution: IExecutionResponse) {
const { workflowData } = execution;
return new Workflow({
id: workflowData.id,
name: workflowData.name,
nodes: workflowData.nodes,
connections: workflowData.connections,
active: workflowData.active,
active: getWorkflowActiveStatusFromWorkflowData(workflowData),
nodeTypes: this.nodeTypes,
staticData: workflowData.staticData,
settings: workflowData.settings,

View File

@ -29,6 +29,7 @@ import type {
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { getWorkflowActiveStatusFromWorkflowData } from '@/executions/execution.utils';
import { NodeTypes } from '@/node-types';
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
@ -70,13 +71,14 @@ export class WaitingWebhooks implements IWebhookManager {
);
}
private createWorkflow(workflowData: IWorkflowBase) {
// TODO: fix the type here - it should be execution workflowData
protected createWorkflow(workflowData: IWorkflowBase) {
return new Workflow({
id: workflowData.id,
name: workflowData.name,
nodes: workflowData.nodes,
connections: workflowData.connections,
active: workflowData.active,
active: getWorkflowActiveStatusFromWorkflowData(workflowData),
nodeTypes: this.nodeTypes,
staticData: workflowData.staticData,
settings: workflowData.settings,

View File

@ -92,6 +92,10 @@ export function getRunData(
};
}
/**
* Loads workflow data for sub-workflow execution.
* Uses the active version when available.
*/
export async function getWorkflowData(
workflowInfo: IExecuteWorkflowInfo,
parentWorkflowId: string,
@ -105,27 +109,35 @@ export async function getWorkflowData(
let workflowData: IWorkflowBase | null;
if (workflowInfo.id !== undefined) {
const relations = Container.get(GlobalConfig).tags.disabled ? [] : ['tags'];
const baseRelations = ['activeVersion'];
const relations = Container.get(GlobalConfig).tags.disabled
? [...baseRelations]
: [...baseRelations, 'tags'];
workflowData = await Container.get(WorkflowRepository).get(
const workflowFromDb = await Container.get(WorkflowRepository).get(
{ id: workflowInfo.id },
{ relations },
);
if (workflowData === undefined || workflowData === null) {
if (workflowFromDb === undefined || workflowFromDb === null) {
throw new UnexpectedError('Workflow does not exist.', {
extra: { workflowId: workflowInfo.id },
});
}
if (workflowFromDb.activeVersion) {
workflowFromDb.nodes = workflowFromDb.activeVersion.nodes;
workflowFromDb.connections = workflowFromDb.activeVersion.connections;
}
workflowData = workflowFromDb;
} else {
workflowData = workflowInfo.code ?? null;
if (workflowData) {
if (!workflowData.id) {
workflowData.id = parentWorkflowId;
}
if (!workflowData.settings) {
workflowData.settings = parentWorkflowSettings;
}
workflowData.settings ??= parentWorkflowSettings;
}
}
@ -183,7 +195,7 @@ async function startExecution(
name: workflowName,
nodes: workflowData.nodes,
connections: workflowData.connections,
active: workflowData.active,
active: workflowData.activeVersionId !== null,
nodeTypes,
staticData: workflowData.staticData,
settings: workflowData.settings,

View File

@ -1,4 +1,5 @@
import { CredentialsRepository } from '@n8n/db';
import type { WorkflowEntity, WorkflowHistory } from '@n8n/db';
import { Container } from '@n8n/di';
import type {
IDataObject,
@ -215,3 +216,28 @@ export function shouldRestartParentExecution(
}
return parentExecution.shouldResume;
}
/**
* Determines the value to set for a workflow's active version based on the provided parameters.
* Always updates the active version to the current version for active workflows, clears it when deactivating.
*
* @param dbWorkflow - The current workflow entity from the database, before the update
* @param updatedVersion - The workflow history version of the updated workflow
* @param updatedActive - Optional boolean indicating if the workflow's active status is being updated
* @returns The workflow history version to set as active, null if deactivating, or the existing active version if unchanged
*/
export function getActiveVersionUpdateValue(
dbWorkflow: WorkflowEntity,
updatedVersion: WorkflowHistory,
updatedActive?: boolean,
) {
if (updatedActive) {
return updatedVersion;
}
if (updatedActive === false) {
return null;
}
return dbWorkflow.activeVersionId ? updatedVersion : null;
}

View File

@ -235,7 +235,7 @@ export class WorkflowRunner {
name: data.workflowData.name,
nodes: data.workflowData.nodes,
connections: data.workflowData.connections,
active: data.workflowData.active,
active: data.workflowData.activeVersionId !== null,
nodeTypes: this.nodeTypes,
staticData: data.workflowData.staticData,
settings: workflowSettings,

View File

@ -93,7 +93,11 @@ describe('WorkflowExecutionService', () => {
describe('runWorkflow()', () => {
test('should call `WorkflowRunner.run()`', async () => {
const node = mock<INode>();
const workflow = mock<IWorkflowBase>({ active: true, nodes: [node] });
const workflow = mock<IWorkflowBase>({
active: true,
activeVersionId: 'some-version-id',
nodes: [node],
});
workflowRunner.run.mockResolvedValue('fake-execution-id');
@ -104,6 +108,10 @@ describe('WorkflowExecutionService', () => {
});
describe('executeManually()', () => {
beforeEach(() => {
workflowRunner.run.mockClear();
});
test('should call `WorkflowRunner.run()` with correct parameters with default partial execution logic', async () => {
const executionId = 'fake-execution-id';
const userId = 'user-id';
@ -253,6 +261,7 @@ describe('WorkflowExecutionService', () => {
id: 'abc',
name: 'test',
active: false,
activeVersionId: null,
isArchived: false,
pinData: {
[pinnedTrigger.name]: [{ json: {} }],
@ -320,6 +329,7 @@ describe('WorkflowExecutionService', () => {
id: 'abc',
name: 'test',
active: false,
activeVersionId: null,
isArchived: false,
pinData: {
[pinnedTrigger.name]: [{ json: {} }],
@ -355,6 +365,38 @@ describe('WorkflowExecutionService', () => {
});
expect(result).toEqual({ executionId });
});
test('should force current version for manual execution even if workflow has active version', async () => {
const executionId = 'fake-execution-id';
const userId = 'user-id';
const user = mock<User>({ id: userId });
const runPayload: WorkflowRequest.ManualRunPayload = {
workflowData: {
id: 'workflow-id',
name: 'Test Workflow',
active: true,
activeVersionId: 'version-123',
isArchived: false,
nodes: [],
connections: {},
createdAt: new Date(),
updatedAt: new Date(),
},
startNodes: [],
destinationNode: undefined,
};
workflowRunner.run.mockResolvedValue(executionId);
const result = await workflowExecutionService.executeManually(runPayload, user);
expect(workflowRunner.run).toHaveBeenCalledTimes(1);
const callArgs = workflowRunner.run.mock.calls[0][0];
expect(callArgs.workflowData.active).toBe(false);
expect(callArgs.workflowData.activeVersionId).toBe(null);
expect(callArgs.executionMode).toBe('manual');
expect(result).toEqual({ executionId });
});
});
describe('selectPinnedActivatorStarter()', () => {
@ -625,6 +667,7 @@ describe('WorkflowExecutionService', () => {
id: 'error-workflow-id',
name: 'Error Workflow',
active: false,
activeVersionId: null,
isArchived: false,
pinData: {},
nodes: [errorTriggerNode],

View File

@ -165,6 +165,7 @@ export class WorkflowExecutionService {
// For manual testing always set to not active
workflowData.active = false;
workflowData.activeVersionId = null;
// Start the workflow
const data: IWorkflowExecutionDataProcess = {
@ -283,7 +284,7 @@ export class WorkflowExecutionService {
nodeTypes: this.nodeTypes,
nodes: workflowData.nodes,
connections: workflowData.connections,
active: workflowData.active,
active: workflowData.activeVersion !== null,
staticData: workflowData.staticData,
settings: workflowData.settings,
});
@ -438,7 +439,7 @@ export class WorkflowExecutionService {
new Workflow({
nodes: workflow.nodes,
connections: workflow.connections,
active: workflow.active,
active: workflow.activeVersionId !== null,
nodeTypes: this.nodeTypes,
}).getParentNodes(destinationNode),
);
@ -466,7 +467,7 @@ export class WorkflowExecutionService {
const parentNodeNames = new Workflow({
nodes: workflow.nodes,
connections: workflow.connections,
active: workflow.active,
active: workflow.activeVersionId !== null,
nodeTypes: this.nodeTypes,
}).getParentNodes(firstStartNodeName);

View File

@ -24,6 +24,7 @@ export class WorkflowFinderService {
options: {
includeTags?: boolean;
includeParentFolder?: boolean;
includeActiveVersion?: boolean;
em?: EntityManager;
} = {},
) {
@ -50,6 +51,7 @@ export class WorkflowFinderService {
where,
includeTags: options.includeTags,
includeParentFolder: options.includeParentFolder,
includeActiveVersion: options.includeActiveVersion,
em: options.em,
});

View File

@ -34,6 +34,6 @@ export class WorkflowHistoryManager {
}
const pruneDateTime = DateTime.now().minus({ hours: pruneHours }).toJSDate();
await this.workflowHistoryRepo.deleteEarlierThanExceptCurrent(pruneDateTime);
await this.workflowHistoryRepo.deleteEarlierThanExceptCurrentAndActive(pruneDateTime);
}
}

View File

@ -1,7 +1,9 @@
import { Logger } from '@n8n/backend-common';
import type { User, WorkflowHistory } from '@n8n/db';
import { WorkflowHistoryRepository } from '@n8n/db';
import type { User } from '@n8n/db';
import { WorkflowHistory, WorkflowHistoryRepository } from '@n8n/db';
import { Service } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager } from '@n8n/typeorm';
import type { IWorkflowBase } from 'n8n-workflow';
import { ensureError, UnexpectedError } from 'n8n-workflow';
@ -64,15 +66,24 @@ export class WorkflowHistoryService {
return hist;
}
async saveVersion(user: User, workflow: IWorkflowBase, workflowId: string) {
async saveVersion(
user: User,
workflow: IWorkflowBase,
workflowId: string,
transactionManager?: EntityManager,
) {
if (!workflow.nodes || !workflow.connections) {
throw new UnexpectedError(
`Cannot save workflow history: nodes and connections are required for workflow ${workflowId}`,
);
}
const repository = transactionManager
? transactionManager.getRepository(WorkflowHistory)
: this.workflowHistoryRepository;
try {
await this.workflowHistoryRepository.insert({
await repository.insert({
authors: user.firstName + ' ' + user.lastName,
connections: workflow.connections,
nodes: workflow.nodes,

View File

@ -329,7 +329,7 @@ export class EnterpriseWorkflowService {
}
// 6. deactivate workflow if necessary
const wasActive = workflow.active;
const wasActive = workflow.activeVersionId !== null;
if (wasActive) {
await this.activeWorkflowManager.remove(workflowId);
}
@ -395,14 +395,14 @@ export class EnterpriseWorkflowService {
// 2. Get all workflows in the nested folders
const workflows = await this.workflowRepository.find({
select: ['id', 'active', 'shared'],
select: ['id', 'activeVersionId', 'shared'],
relations: ['shared', 'shared.project'],
where: {
parentFolder: { id: In([...childrenFolderIds, sourceFolderId]) },
},
});
const activeWorkflows = workflows.filter((w) => w.active).map((w) => w.id);
const activeWorkflows = workflows.filter((w) => w.activeVersionId !== null).map((w) => w.id);
// 3. get destination project
const destinationProject = await this.projectService.getProjectWithScope(

View File

@ -205,9 +205,12 @@ export class WorkflowService {
parentFolderId?: string,
forceSave?: boolean,
): Promise<WorkflowEntity> {
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:update',
]);
const workflow = await this.workflowFinderService.findWorkflowForUser(
workflowId,
user,
['workflow:update'],
{ includeActiveVersion: true },
);
if (!workflow) {
this.logger.warn('User attempted to update a workflow without permissions', {
@ -230,7 +233,10 @@ export class WorkflowService {
);
}
if (Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active'])).length > 0) {
if (
Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active', 'activeVersionId']))
.length > 0
) {
// Update the workflow's version when changing properties such as
// `name`, `pinData`, `nodes`, `connections`, `settings` or `tags`
// This is necessary for collaboration to work properly - even when only name or settings
@ -246,8 +252,22 @@ export class WorkflowService {
);
}
// Convert 'active' boolean from frontend to 'activeVersionId' for backend
if ('active' in workflowUpdateData) {
if (workflowUpdateData.active) {
workflowUpdateData.activeVersionId = workflowUpdateData.versionId ?? workflow.versionId;
} else {
workflowUpdateData.activeVersionId = null;
}
}
const versionChanged =
workflowUpdateData.versionId && workflowUpdateData.versionId !== workflow.versionId;
const wasActive = workflow.activeVersionId !== null;
const isNowActive = workflowUpdateData.active ?? wasActive;
const activationStatusChanged = isNowActive !== wasActive;
const needsActiveVersionUpdate = activationStatusChanged || (versionChanged && isNowActive);
if (versionChanged) {
// To save a version, we need both nodes and connections
workflowUpdateData.nodes = workflowUpdateData.nodes ?? workflow.nodes;
@ -268,7 +288,7 @@ export class WorkflowService {
* If a trigger or poller in the workflow was updated, the new value
* will take effect only on removing and re-adding.
*/
if (workflow.active) {
if (wasActive) {
await this.activeWorkflowManager.remove(workflowId);
}
@ -308,9 +328,30 @@ export class WorkflowService {
'staticData',
'pinData',
'versionId',
'activeVersionId',
'description',
]);
// Save the workflow to history first, so we can retrieve the complete version object for the update
if (versionChanged) {
await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId);
}
if (needsActiveVersionUpdate) {
const versionIdToFetch = versionChanged ? workflowUpdateData.versionId : workflow.versionId;
const version = await this.workflowHistoryService.getVersion(
user,
workflowId,
versionIdToFetch,
);
updatePayload.activeVersion = WorkflowHelpers.getActiveVersionUpdateValue(
workflow,
version,
isNowActive,
);
}
if (parentFolderId) {
const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflow.id);
if (parentFolderId !== PROJECT_ROOT) {
@ -334,10 +375,6 @@ export class WorkflowService {
await this.workflowTagMappingRepository.overwriteTaggings(workflowId, tagIds);
}
if (versionChanged) {
await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId);
}
const relations = tagsDisabled ? [] : ['tags'];
// We sadly get nothing back from "update". Neither if it updated a record
@ -366,11 +403,7 @@ export class WorkflowService {
publicApi: false,
});
// Check if workflow activation status changed
const wasActive = workflow.active;
const isNowActive = updatedWorkflow.active;
if (isNowActive && !wasActive) {
if (activationStatusChanged && isNowActive) {
// Workflow is being activated
this.eventService.emit('workflow-activated', {
user,
@ -378,7 +411,7 @@ export class WorkflowService {
workflow: updatedWorkflow,
publicApi: false,
});
} else if (!isNowActive && wasActive) {
} else if (activationStatusChanged && !isNowActive) {
// Workflow is being deactivated
this.eventService.emit('workflow-deactivated', {
user,
@ -388,21 +421,24 @@ export class WorkflowService {
});
}
if (updatedWorkflow.active) {
if (isNowActive) {
// When the workflow is supposed to be active add it again
try {
await this.externalHooks.run('workflow.activate', [updatedWorkflow]);
await this.activeWorkflowManager.add(workflowId, workflow.active ? 'update' : 'activate');
await this.activeWorkflowManager.add(workflowId, wasActive ? 'update' : 'activate');
} catch (error) {
// If workflow could not be activated set it again to inactive
// and revert the versionId change so UI remains consistent
// and revert the versionId and activeVersionId change so UI remains consistent
await this.workflowRepository.update(workflowId, {
active: false,
activeVersion: null,
versionId: workflow.versionId,
});
// Also set it in the returned data
updatedWorkflow.active = false;
updatedWorkflow.activeVersionId = null;
updatedWorkflow.activeVersion = null;
// Emit deactivation event since activation failed
this.eventService.emit('workflow-deactivated', {
@ -490,7 +526,7 @@ export class WorkflowService {
throw new BadRequestError('Workflow is already archived.');
}
if (workflow.active) {
if (workflow.activeVersionId !== null) {
await this.activeWorkflowManager.remove(workflowId);
}
@ -498,10 +534,13 @@ export class WorkflowService {
workflow.versionId = versionId;
workflow.isArchived = true;
workflow.active = false;
workflow.activeVersionId = null;
workflow.activeVersion = null;
await this.workflowRepository.update(workflowId, {
isArchived: true,
active: false,
activeVersion: null,
versionId,
});

View File

@ -181,11 +181,29 @@ export class WorkflowsController {
await transactionManager.save<SharedWorkflow>(newSharedWorkflow);
await this.workflowHistoryService.saveVersion(
req.user,
workflow,
workflow.id,
transactionManager,
);
const shouldActivate = req.body.active === true;
if (shouldActivate) {
workflow.activeVersionId = workflow.versionId;
await transactionManager.save(workflow);
}
return await this.workflowFinderService.findWorkflowForUser(
workflow.id,
req.user,
['workflow:read'],
{ em: transactionManager, includeTags: true, includeParentFolder: true },
{
em: transactionManager,
includeTags: true,
includeParentFolder: true,
includeActiveVersion: true,
},
);
});
@ -194,8 +212,6 @@ export class WorkflowsController {
throw new InternalServerError('Failed to save workflow');
}
await this.workflowHistoryService.saveVersion(req.user, savedWorkflow, savedWorkflow.id);
if (tagIds && !this.globalConfig.tags.disabled && savedWorkflow.tags) {
savedWorkflow.tags = this.tagService.sortByRequestOrder(savedWorkflow.tags, {
requestOrder: tagIds,

View File

@ -127,7 +127,7 @@ describe('Cross-Project Access Control Tests', () => {
});
afterAll(async () => {
await testDb.truncate(['User']);
await testDb.truncate(['User', 'ProjectRelation']);
await cleanupRolesAndScopes();
});

View File

@ -166,7 +166,7 @@ describe('Custom Role Functionality Tests', () => {
});
afterAll(async () => {
await testDb.truncate(['User']);
await testDb.truncate(['User', 'ProjectRelation']);
await cleanupRolesAndScopes();
});

View File

@ -135,7 +135,7 @@ describe('Resource Access Control Matrix Tests', () => {
});
afterAll(async () => {
await testDb.truncate(['User']);
await testDb.truncate(['User', 'ProjectRelation']);
await cleanupRolesAndScopes();
});

View File

@ -1,5 +1,10 @@
import { createWorkflowWithHistory, testDb, mockInstance } from '@n8n/backend-test-utils';
import type { Project, WebhookEntity } from '@n8n/db';
import {
createWorkflowWithHistory,
setActiveVersion,
testDb,
mockInstance,
} from '@n8n/backend-test-utils';
import type { IWorkflowDb, Project, User, WebhookEntity } from '@n8n/db';
import { WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
@ -43,8 +48,11 @@ const externalHooks = mockInstance(ExternalHooks);
let activeWorkflowManager: ActiveWorkflowManager;
let createActiveWorkflow: () => Promise<IWorkflowBase>;
let createActiveWorkflow: (
workflowOptions?: Parameters<typeof createWorkflowWithHistory>[0],
) => Promise<IWorkflowBase>;
let createInactiveWorkflow: () => Promise<IWorkflowBase>;
let owner: User;
beforeAll(async () => {
await testDb.init();
@ -64,15 +72,19 @@ beforeAll(async () => {
await utils.initNodeTypes(nodes);
const owner = await createOwner();
createActiveWorkflow = async () => await createWorkflowWithHistory({ active: true }, owner);
owner = await createOwner();
createActiveWorkflow = async (workflowOptions: Partial<IWorkflowDb> = {}) => {
const workflow = await createWorkflowWithHistory({ active: true, ...workflowOptions }, owner);
await setActiveVersion(workflow.id, workflow.versionId);
return workflow;
};
createInactiveWorkflow = async () => await createWorkflowWithHistory({ active: false }, owner);
Container.get(InstanceSettings).markAsLeader();
});
afterEach(async () => {
await activeWorkflowManager.removeAll();
await testDb.truncate(['WorkflowEntity', 'WebhookEntity']);
await testDb.truncate(['WorkflowEntity', 'WebhookEntity', 'WorkflowHistory']);
jest.clearAllMocks();
});
@ -176,7 +188,7 @@ describe('add()', () => {
);
// Create a workflow which has a form trigger
const dbWorkflow = await createWorkflowWithHistory({
const dbWorkflow = await createActiveWorkflow({
nodes: [
{
id: 'uuid-1',
@ -194,7 +206,7 @@ describe('add()', () => {
expect(updateWorkflowTriggerCountSpy).toHaveBeenCalledWith(dbWorkflow.id, 1);
});
test('should activate an initially inactive workflow in memory', async () => {
test('should activate a workflow after its active status changes from false to true', async () => {
await activeWorkflowManager.init();
const dbWorkflow = await createInactiveWorkflow();
@ -203,6 +215,10 @@ describe('add()', () => {
// Verify it's not active in memory yet
expect(activeWorkflowManager.allActiveInMemory()).toHaveLength(0);
// Simulate the workflow being activated
await setActiveVersion(dbWorkflow.id, dbWorkflow.versionId!);
await Container.get(WorkflowRepository).update(dbWorkflow.id, { active: true });
await activeWorkflowManager.add(dbWorkflow.id, 'activate');
expect(activeWorkflowManager.allActiveInMemory()).toHaveLength(1);

View File

@ -48,8 +48,8 @@ test('import:workflow should import active workflow and deactivate it', async ()
};
expect(after).toMatchObject({
workflows: [
expect.objectContaining({ name: 'active-workflow', active: false }),
expect.objectContaining({ name: 'inactive-workflow', active: false }),
expect.objectContaining({ name: 'active-workflow', active: false, activeVersionId: null }),
expect.objectContaining({ name: 'inactive-workflow', active: false, activeVersionId: null }),
],
sharings: [
expect.objectContaining({
@ -89,8 +89,8 @@ test('import:workflow should import active workflow from combined file and deact
};
expect(after).toMatchObject({
workflows: [
expect.objectContaining({ name: 'active-workflow', active: false }),
expect.objectContaining({ name: 'inactive-workflow', active: false }),
expect.objectContaining({ name: 'active-workflow', active: false, activeVersionId: null }),
expect.objectContaining({ name: 'inactive-workflow', active: false, activeVersionId: null }),
],
sharings: [
expect.objectContaining({
@ -127,7 +127,9 @@ test('import:workflow can import a single workflow object', async () => {
sharings: await getAllSharedWorkflows(),
};
expect(after).toMatchObject({
workflows: [expect.objectContaining({ name: 'active-workflow', active: false })],
workflows: [
expect.objectContaining({ name: 'active-workflow', active: false, activeVersionId: null }),
],
sharings: [
expect.objectContaining({
workflowId: '998',

View File

@ -1,9 +1,12 @@
import {
mockInstance,
testDb,
createWorkflowWithTrigger,
createWorkflowWithTriggerAndHistory,
createManyActiveWorkflows,
getAllWorkflows,
} from '@n8n/backend-test-utils';
import { WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { UpdateWorkflowCommand } from '@/commands/update/workflow';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
@ -13,7 +16,7 @@ mockInstance(LoadNodesAndCredentials);
const command = setupTestCommand(UpdateWorkflowCommand);
beforeEach(async () => {
await testDb.truncate(['WorkflowEntity']);
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']);
});
test('update:workflow can activate all workflows', async () => {
@ -21,10 +24,11 @@ test('update:workflow can activate all workflows', async () => {
// ARRANGE
//
const workflows = await Promise.all([
createWorkflowWithTrigger({}),
createWorkflowWithTrigger({}),
createWorkflowWithTriggerAndHistory({}),
createWorkflowWithTriggerAndHistory({}),
]);
expect(workflows).toMatchObject([{ active: false }, { active: false }]);
expect(workflows[0].activeVersionId).toBeNull();
expect(workflows[1].activeVersionId).toBeNull();
//
// ACT
@ -34,19 +38,35 @@ test('update:workflow can activate all workflows', async () => {
//
// ASSERT
//
const after = await getAllWorkflows();
expect(after).toMatchObject([{ active: true }, { active: true }]);
// Verify activeVersionId is now set to the current versionId
const workflowRepo = Container.get(WorkflowRepository);
const workflow1 = await workflowRepo.findOne({
where: { id: workflows[0].id },
relations: ['activeVersion'],
});
const workflow2 = await workflowRepo.findOne({
where: { id: workflows[1].id },
relations: ['activeVersion'],
});
expect(workflow1?.activeVersionId).toBe(workflows[0].versionId);
expect(workflow1?.activeVersion?.versionId).toBe(workflows[0].versionId);
expect(workflow2?.activeVersionId).toBe(workflows[1].versionId);
expect(workflow2?.activeVersion?.versionId).toBe(workflows[1].versionId);
});
test('update:workflow can deactivate all workflows', async () => {
//
// ARRANGE
//
const workflows = await Promise.all([
createWorkflowWithTrigger({ active: true }),
createWorkflowWithTrigger({ active: true }),
]);
expect(workflows).toMatchObject([{ active: true }, { active: true }]);
const workflows = await createManyActiveWorkflows(2);
// Verify activeVersionId is set
const workflowRepo = Container.get(WorkflowRepository);
let workflow1 = await workflowRepo.findOneBy({ id: workflows[0].id });
let workflow2 = await workflowRepo.findOneBy({ id: workflows[1].id });
expect(workflow1?.activeVersionId).toBe(workflows[0].versionId);
expect(workflow2?.activeVersionId).toBe(workflows[1].versionId);
//
// ACT
@ -56,8 +76,20 @@ test('update:workflow can deactivate all workflows', async () => {
//
// ASSERT
//
const after = await getAllWorkflows();
expect(after).toMatchObject([{ active: false }, { active: false }]);
// Verify activeVersionId is cleared
workflow1 = await workflowRepo.findOne({
where: { id: workflows[0].id },
relations: ['activeVersion'],
});
workflow2 = await workflowRepo.findOne({
where: { id: workflows[1].id },
relations: ['activeVersion'],
});
expect(workflow1?.activeVersionId).toBeNull();
expect(workflow1?.activeVersion).toBeNull();
expect(workflow2?.activeVersionId).toBeNull();
expect(workflow2?.activeVersion).toBeNull();
});
test('update:workflow can activate a specific workflow', async () => {
@ -66,11 +98,10 @@ test('update:workflow can activate a specific workflow', async () => {
//
const workflows = (
await Promise.all([
createWorkflowWithTrigger({ active: false }),
createWorkflowWithTrigger({ active: false }),
createWorkflowWithTriggerAndHistory(),
createWorkflowWithTriggerAndHistory(),
])
).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id));
expect(workflows).toMatchObject([{ active: false }, { active: false }]);
//
// ACT
@ -81,20 +112,19 @@ test('update:workflow can activate a specific workflow', async () => {
// ASSERT
//
const after = (await getAllWorkflows()).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id));
expect(after).toMatchObject([{ active: true }, { active: false }]);
expect(after).toMatchObject([
{ activeVersionId: workflows[0].versionId },
{ activeVersionId: null },
]);
});
test('update:workflow can deactivate a specific workflow', async () => {
//
// ARRANGE
//
const workflows = (
await Promise.all([
createWorkflowWithTrigger({ active: true }),
createWorkflowWithTrigger({ active: true }),
])
).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id));
expect(workflows).toMatchObject([{ active: true }, { active: true }]);
const workflows = (await createManyActiveWorkflows(2)).sort((wf1, wf2) =>
wf1.id.localeCompare(wf2.id),
);
//
// ACT
@ -105,5 +135,8 @@ test('update:workflow can deactivate a specific workflow', async () => {
// ASSERT
//
const after = (await getAllWorkflows()).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id));
expect(after).toMatchObject([{ active: false }, { active: true }]);
expect(after).toMatchObject([
{ activeVersionId: null },
{ activeVersionId: workflows[1].versionId },
]);
});

View File

@ -1,4 +1,4 @@
import { createManyWorkflows, testDb } from '@n8n/backend-test-utils';
import { createManyActiveWorkflows, testDb } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { StatisticsNames } from '@n8n/db';
import { Container } from '@n8n/di';
@ -36,7 +36,7 @@ describe('CtaService', () => {
])(
'should return %p if user has %d active workflows with %d successful production executions',
async (expected, numWorkflows, numExecutions) => {
const workflows = await createManyWorkflows(numWorkflows, { active: true }, user);
const workflows = await createManyActiveWorkflows(numWorkflows, {}, user);
await Promise.all(
workflows.map(

View File

@ -1,14 +1,17 @@
import {
createWorkflowWithTrigger,
createWorkflowWithTriggerAndHistory,
createWorkflowWithHistory,
createActiveWorkflow,
createManyActiveWorkflows,
createWorkflowWithActiveVersion,
createWorkflow,
getAllWorkflows,
testDb,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { WorkflowRepository, WorkflowDependencyRepository, WorkflowDependencies } from '@n8n/db';
import { Container } from '@n8n/di';
import { createTestRun } from '../../shared/db/evaluation';
import { GlobalConfig } from '@n8n/config';
describe('WorkflowRepository', () => {
beforeAll(async () => {
@ -16,7 +19,7 @@ describe('WorkflowRepository', () => {
});
beforeEach(async () => {
await testDb.truncate(['WorkflowDependency', 'WorkflowEntity']);
await testDb.truncate(['WorkflowDependency', 'WorkflowEntity', 'WorkflowHistory']);
});
afterAll(async () => {
@ -30,10 +33,11 @@ describe('WorkflowRepository', () => {
//
const workflowRepository = Container.get(WorkflowRepository);
const workflows = await Promise.all([
createWorkflowWithTrigger(),
createWorkflowWithTrigger(),
createWorkflowWithTriggerAndHistory(),
createWorkflowWithTriggerAndHistory(),
]);
expect(workflows).toMatchObject([{ active: false }, { active: false }]);
expect(workflows[0].activeVersionId).toBeNull();
expect(workflows[1].activeVersionId).toBeNull();
//
// ACT
@ -43,33 +47,79 @@ describe('WorkflowRepository', () => {
//
// ASSERT
//
const after = await getAllWorkflows();
expect(after).toMatchObject([{ active: true }, { active: true }]);
});
});
const workflow1 = await workflowRepository.findOne({
where: { id: workflows[0].id },
});
const workflow2 = await workflowRepository.findOne({
where: { id: workflows[1].id },
});
describe('deactivateAll', () => {
it('should deactivate all workflows', async () => {
expect(workflow1?.activeVersionId).toBe(workflows[0].versionId);
expect(workflow2?.activeVersionId).toBe(workflows[1].versionId);
});
it('should not change activeVersionId for already-active workflows', async () => {
//
// ARRANGE
//
const workflowRepository = Container.get(WorkflowRepository);
const workflows = await Promise.all([
createWorkflowWithTrigger({ active: true }),
createWorkflowWithTrigger({ active: true }),
]);
expect(workflows).toMatchObject([{ active: true }, { active: true }]);
const activeVersionId = 'old-active-version-id';
// Create workflow with different active and current versions
const workflow = await createWorkflowWithActiveVersion(activeVersionId, {});
const currentVersionId = workflow.versionId;
expect(workflow.active).toBe(true);
expect(workflow.activeVersionId).toBe(activeVersionId);
expect(workflow.versionId).toBe(currentVersionId);
//
// ACT
//
await workflowRepository.activateAll();
//
// ASSERT
//
// activeVersionId should remain unchanged
const after = await workflowRepository.findOne({
where: { id: workflow.id },
});
expect(after?.activeVersionId).toBe(activeVersionId); // Unchanged
expect(after?.versionId).toBe(currentVersionId);
});
});
describe('deactivateAll', () => {
it('should deactivate all workflows and clear activeVersionId', async () => {
//
// ARRANGE
//
const workflowRepository = Container.get(WorkflowRepository);
const workflows = await createManyActiveWorkflows(2);
// Verify activeVersionId is initially set
expect(workflows[0].activeVersionId).not.toBeNull();
expect(workflows[1].activeVersionId).not.toBeNull();
//
// ACT
//
await workflowRepository.deactivateAll();
//
// ASSERT
//
const after = await getAllWorkflows();
expect(after).toMatchObject([{ active: false }, { active: false }]);
// Verify activeVersionId is cleared
const workflow1 = await workflowRepository.findOne({
where: { id: workflows[0].id },
});
const workflow2 = await workflowRepository.findOne({
where: { id: workflows[1].id },
});
expect(workflow1?.activeVersionId).toBeNull();
expect(workflow2?.activeVersionId).toBeNull();
});
});
@ -79,9 +129,9 @@ describe('WorkflowRepository', () => {
// ARRANGE
//
const workflows = await Promise.all([
createWorkflow({ active: true }),
createWorkflow({ active: false }),
createWorkflow({ active: false }),
createActiveWorkflow(),
createWorkflowWithHistory(),
createWorkflowWithHistory(),
]);
//
@ -101,9 +151,9 @@ describe('WorkflowRepository', () => {
// ARRANGE
//
await Promise.all([
createWorkflow({ active: true }),
createWorkflow({ active: false }),
createWorkflow({ active: true }),
createActiveWorkflow(),
createWorkflowWithHistory(),
createActiveWorkflow(),
]);
//

View File

@ -8,6 +8,7 @@ import {
linkUserToProject,
testDb,
mockInstance,
createActiveWorkflow,
} from '@n8n/backend-test-utils';
import type { Project, User } from '@n8n/db';
import { FolderRepository, ProjectRepository, WorkflowRepository } from '@n8n/db';
@ -388,7 +389,6 @@ describe('GET /projects/:projectId/folders/:folderId/credentials', () => {
{
name: 'Test Workflow',
parentFolder: folder,
active: false,
nodes: [
{
parameters: {},
@ -480,7 +480,6 @@ describe('GET /projects/:projectId/folders/:folderId/credentials', () => {
{
name: 'Test Workflow',
parentFolder: folder,
active: false,
nodes: [
{
parameters: {},
@ -1093,8 +1092,8 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => {
});
// Create workflows in the folders
const workflow1 = await createWorkflow({ parentFolder: rootFolder, active: false }, owner);
const workflow2 = await createWorkflow({ parentFolder: childFolder, active: true }, owner);
const workflow1 = await createWorkflow({ parentFolder: rootFolder }, owner);
const workflow2 = await createActiveWorkflow({ parentFolder: childFolder }, owner);
await authOwnerAgent.delete(`/projects/${project.id}/folders/${rootFolder.id}`);
@ -1115,6 +1114,7 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => {
expect(workflow1InDb?.isArchived).toBe(true);
expect(workflow1InDb?.parentFolder).toBe(null);
expect(workflow1InDb?.active).toBe(false);
expect(workflow1InDb?.activeVersionId).toBeNull();
const workflow2InDb = await workflowRepository.findOne({
where: { id: workflow2.id },
@ -1124,6 +1124,7 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => {
expect(workflow2InDb?.isArchived).toBe(true);
expect(workflow2InDb?.parentFolder).toBe(null);
expect(workflow2InDb?.active).toBe(false);
expect(workflow2InDb?.activeVersionId).toBeNull();
});
test('should transfer folder contents when transferToFolderId is specified', async () => {
@ -1823,7 +1824,7 @@ describe('PUT /projects/:projectId/folders/:folderId/transfer', () => {
const sourceFolder1 = await createFolder(sourceProject, { name: 'Source Folder 1' });
await createWorkflow({ active: true, parentFolder: sourceFolder1 }, destinationProject);
await createActiveWorkflow({ parentFolder: sourceFolder1 }, destinationProject);
await testServer
.authAgentFor(member)
@ -1856,7 +1857,7 @@ describe('PUT /projects/:projectId/folders/:folderId/transfer', () => {
const sourceFolder1 = await createFolder(sourceProject, { name: 'Source Folder 1' });
await createWorkflow({ active: true }, destinationProject);
await createActiveWorkflow({}, destinationProject);
await testServer
.authAgentFor(member)
@ -2599,7 +2600,7 @@ describe('PUT /projects/:projectId/folders/:folderId/transfer', () => {
parentFolder: sourceFolder1,
});
await createWorkflow({ active: true, parentFolder: sourceFolder1 }, sourceProject);
await createActiveWorkflow({ parentFolder: sourceFolder1 }, sourceProject);
await createWorkflow({ parentFolder: sourceFolder2 }, sourceProject);
activeWorkflowManager.add.mockRejectedValue(new ApplicationError('Oh no!'));

View File

@ -5,6 +5,7 @@ import {
getWorkflowById,
newWorkflow,
testDb,
createActiveWorkflow,
} from '@n8n/backend-test-utils';
import { DatabaseConfig } from '@n8n/config';
import type { Project, User } from '@n8n/db';
@ -120,7 +121,7 @@ describe('ImportService', () => {
});
test('should deactivate imported workflow if active', async () => {
const workflowToImport = await createWorkflow({ active: true });
const workflowToImport = await createActiveWorkflow();
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id);
@ -129,6 +130,7 @@ describe('ImportService', () => {
if (!dbWorkflow) fail('Expected to find workflow');
expect(dbWorkflow.active).toBe(false);
expect(dbWorkflow.activeVersionId).toBeNull();
});
test('should leave intact new-format credentials', async () => {
@ -227,7 +229,7 @@ describe('ImportService', () => {
});
test('should remove workflow from ActiveWorkflowManager when workflow has ID', async () => {
const workflowWithId = await createWorkflow({ active: true });
const workflowWithId = await createActiveWorkflow();
await importService.importWorkflows([workflowWithId], ownerPersonalProject.id);
expect(mockActiveWorkflowManager.remove).toHaveBeenCalledWith(workflowWithId.id);

View File

@ -1,4 +1,4 @@
import { createManyWorkflows, testDb } from '@n8n/backend-test-utils';
import { createManyActiveWorkflows, createManyWorkflows, testDb } from '@n8n/backend-test-utils';
import { StatisticsNames, LicenseMetricsRepository, WorkflowStatisticsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
@ -33,7 +33,7 @@ describe('LicenseMetricsRepository', () => {
describe('getLicenseRenewalMetrics', () => {
test('should return license renewal metrics', async () => {
const [firstWorkflow, secondWorkflow] = await createManyWorkflows(2, { active: false });
const [firstWorkflow, secondWorkflow] = await createManyWorkflows(2);
await Promise.all([
createOwner(),
@ -42,7 +42,7 @@ describe('LicenseMetricsRepository', () => {
createMember(),
createUser({ disabled: true }),
createManyCredentials(2),
createManyWorkflows(3, { active: true }),
createManyActiveWorkflows(3),
]);
await Promise.all([
@ -85,7 +85,8 @@ describe('LicenseMetricsRepository', () => {
});
test('should handle zero execution statistics correctly', async () => {
await Promise.all([createOwner(), createManyWorkflows(3, { active: true })]);
const owner = await createOwner();
await createManyActiveWorkflows(3, {}, owner);
const metrics = await licenseMetricsRepository.getLicenseRenewalMetrics();

View File

@ -1,4 +1,4 @@
import { createWorkflow, newWorkflow } from '@n8n/backend-test-utils';
import { createActiveWorkflow } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
@ -361,8 +361,7 @@ describe('PrometheusMetricsService', () => {
expect(lines).toContain('n8n_test_active_workflow_count 0');
const workflow = newWorkflow({ active: true });
await createWorkflow(workflow);
await createActiveWorkflow({});
const workflowRepository = Container.get(WorkflowRepository);
const activeWorkflowCount = await workflowRepository.getActiveCount();

View File

@ -4,6 +4,7 @@ import {
createWorkflowWithTriggerAndHistory,
testDb,
mockInstance,
createActiveWorkflow,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { Project, TagEntity, User } from '@n8n/db';
@ -110,6 +111,7 @@ describe('GET /workflows', () => {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
@ -123,6 +125,7 @@ describe('GET /workflows', () => {
expect(name).toBeDefined();
expect(connections).toBeDefined();
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(staticData).toBeDefined();
expect(nodes).toBeDefined();
expect(tags).toBeDefined();
@ -161,6 +164,7 @@ describe('GET /workflows', () => {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
@ -174,6 +178,7 @@ describe('GET /workflows', () => {
expect(name).toBeDefined();
expect(connections).toBeDefined();
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(staticData).toBeDefined();
expect(nodes).toBeDefined();
expect(tags).toBeDefined();
@ -203,6 +208,7 @@ describe('GET /workflows', () => {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
@ -216,6 +222,7 @@ describe('GET /workflows', () => {
expect(name).toBeDefined();
expect(connections).toBeDefined();
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(staticData).toBeDefined();
expect(nodes).toBeDefined();
expect(settings).toBeDefined();
@ -244,8 +251,18 @@ describe('GET /workflows', () => {
expect(response.body.data.length).toBe(2);
for (const workflow of response.body.data) {
const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
workflow;
const {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
name,
createdAt,
updatedAt,
} = workflow;
expect(id).toBeDefined();
expect([workflow1.id, workflow2.id].includes(id)).toBe(true);
@ -253,6 +270,7 @@ describe('GET /workflows', () => {
expect(name).toBeDefined();
expect(connections).toBeDefined();
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(staticData).toBeDefined();
expect(nodes).toBeDefined();
expect(settings).toBeDefined();
@ -324,6 +342,7 @@ describe('GET /workflows', () => {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
@ -337,6 +356,7 @@ describe('GET /workflows', () => {
expect(name).toBe(workflowName);
expect(connections).toBeDefined();
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(staticData).toBeDefined();
expect(nodes).toBeDefined();
expect(settings).toBeDefined();
@ -365,6 +385,7 @@ describe('GET /workflows', () => {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
@ -378,6 +399,7 @@ describe('GET /workflows', () => {
expect(name).toBeDefined();
expect(connections).toBeDefined();
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(staticData).toBeDefined();
expect(nodes).toBeDefined();
expect(tags).toBeDefined();
@ -427,6 +449,57 @@ describe('GET /workflows', () => {
expect(pinData).not.toBeDefined();
}
});
test('should return activeVersion for all workflows', async () => {
const inactiveWorkflow = await createWorkflow({}, member);
const activeWorkflow = await createWorkflowWithTriggerAndHistory({}, member);
await authMemberAgent.post(`/workflows/${activeWorkflow.id}/activate`);
const response = await authMemberAgent.get('/workflows');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(2);
const inactiveInResponse = response.body.data.find(
(w: { id: string }) => w.id === inactiveWorkflow.id,
);
const activeInResponse = response.body.data.find(
(w: { id: string }) => w.id === activeWorkflow.id,
);
// Inactive workflow should have null activeVersion
expect(inactiveInResponse).toBeDefined();
expect(inactiveInResponse.activeVersionId).toBeNull();
// Active workflow should have populated activeVersion
expect(activeInResponse).toBeDefined();
expect(activeInResponse.active).toBe(true);
expect(activeInResponse.activeVersion).toBeDefined();
expect(activeInResponse.activeVersion).not.toBeNull();
expect(activeInResponse.activeVersion.versionId).toBe(activeWorkflow.versionId);
expect(activeInResponse.activeVersion.nodes).toEqual(activeWorkflow.nodes);
expect(activeInResponse.activeVersion.connections).toEqual(activeWorkflow.connections);
});
test('should return activeVersion when filtering by active=true', async () => {
await createWorkflow({}, member);
const activeWorkflow = await createWorkflowWithTriggerAndHistory({}, member);
await authMemberAgent.post(`/workflows/${activeWorkflow.id}/activate`);
const response = await authMemberAgent.get('/workflows?active=true');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(1);
const workflow = response.body.data[0];
expect(workflow.id).toBe(activeWorkflow.id);
expect(workflow.active).toBe(true);
expect(workflow.activeVersion).toBeDefined();
expect(workflow.activeVersion).not.toBeNull();
expect(workflow.activeVersion.versionId).toBe(activeWorkflow.versionId);
});
});
describe('GET /workflows/:id', () => {
@ -451,6 +524,7 @@ describe('GET /workflows/:id', () => {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
@ -464,6 +538,7 @@ describe('GET /workflows/:id', () => {
expect(name).toEqual(workflow.name);
expect(connections).toEqual(workflow.connections);
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(staticData).toEqual(workflow.staticData);
expect(nodes).toEqual(workflow.nodes);
expect(tags).toEqual([]);
@ -480,13 +555,24 @@ describe('GET /workflows/:id', () => {
expect(response.statusCode).toBe(200);
const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
response.body;
const {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
name,
createdAt,
updatedAt,
} = response.body;
expect(id).toEqual(workflow.id);
expect(name).toEqual(workflow.name);
expect(connections).toEqual(workflow.connections);
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(staticData).toEqual(workflow.staticData);
expect(nodes).toEqual(workflow.nodes);
expect(settings).toEqual(workflow.settings);
@ -513,6 +599,34 @@ describe('GET /workflows/:id', () => {
expect(pinData).not.toBeDefined();
});
test('should return activeVersion as null for inactive workflow', async () => {
const workflow = await createWorkflow({}, member);
const response = await authMemberAgent.get(`/workflows/${workflow.id}`);
expect(response.statusCode).toBe(200);
expect(response.body.active).toBe(false);
expect(response.body.activeVersionId).toBe(null);
expect(response.body.activeVersion).toBeNull();
});
test('should return activeVersion for active workflow', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({}, member);
await authMemberAgent.post(`/workflows/${workflow.id}/activate`);
const response = await authMemberAgent.get(`/workflows/${workflow.id}`);
expect(response.statusCode).toBe(200);
expect(response.body.active).toBe(true);
expect(response.body.activeVersionId).toBe(workflow.versionId);
expect(response.body.activeVersion).toBeDefined();
expect(response.body.activeVersion).not.toBeNull();
expect(response.body.activeVersion.versionId).toBe(workflow.versionId);
expect(response.body.activeVersion.nodes).toEqual(workflow.nodes);
expect(response.body.activeVersion.connections).toEqual(workflow.connections);
});
});
describe('DELETE /workflows/:id', () => {
@ -533,13 +647,24 @@ describe('DELETE /workflows/:id', () => {
expect(response.statusCode).toBe(200);
const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
response.body;
const {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
name,
createdAt,
updatedAt,
} = response.body;
expect(id).toEqual(workflow.id);
expect(name).toEqual(workflow.name);
expect(connections).toEqual(workflow.connections);
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(staticData).toEqual(workflow.staticData);
expect(nodes).toEqual(workflow.nodes);
expect(settings).toEqual(workflow.settings);
@ -562,13 +687,24 @@ describe('DELETE /workflows/:id', () => {
expect(response.statusCode).toBe(200);
const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
response.body;
const {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
name,
createdAt,
updatedAt,
} = response.body;
expect(id).toEqual(workflow.id);
expect(name).toEqual(workflow.name);
expect(connections).toEqual(workflow.connections);
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(staticData).toEqual(workflow.staticData);
expect(nodes).toEqual(workflow.nodes);
expect(settings).toEqual(workflow.settings);
@ -630,13 +766,24 @@ describe('POST /workflows/:id/activate', () => {
expect(response.statusCode).toBe(200);
const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
response.body;
const {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
name,
createdAt,
updatedAt,
} = response.body;
expect(id).toEqual(workflow.id);
expect(name).toEqual(workflow.name);
expect(connections).toEqual(workflow.connections);
expect(active).toBe(true);
expect(activeVersionId).toBe(workflow.versionId);
expect(staticData).toEqual(workflow.staticData);
expect(nodes).toEqual(workflow.nodes);
expect(settings).toEqual(workflow.settings);
@ -652,24 +799,58 @@ describe('POST /workflows/:id/activate', () => {
relations: ['workflow'],
});
expect(sharedWorkflow?.workflow.active).toBe(true);
expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId);
// check whether the workflow is on the active workflow runner
expect(await activeWorkflowManager.isActive(workflow.id)).toBe(true);
});
test('should set activeVersionId when activating workflow', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({}, member);
const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`);
expect(response.statusCode).toBe(200);
expect(response.body.active).toBe(true);
expect(response.body.activeVersionId).toBe(workflow.versionId);
const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({
where: {
projectId: memberPersonalProject.id,
workflowId: workflow.id,
},
relations: ['workflow', 'workflow.activeVersion'],
});
expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId);
expect(sharedWorkflow?.workflow.activeVersion?.versionId).toBe(workflow.versionId);
expect(sharedWorkflow?.workflow.activeVersion?.nodes).toEqual(workflow.nodes);
expect(sharedWorkflow?.workflow.activeVersion?.connections).toEqual(workflow.connections);
});
test('should set non-owned workflow as active when owner', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({}, member);
const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`).expect(200);
const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
response.body;
const {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
name,
createdAt,
updatedAt,
} = response.body;
expect(id).toEqual(workflow.id);
expect(name).toEqual(workflow.name);
expect(connections).toEqual(workflow.connections);
expect(active).toBe(true);
expect(activeVersionId).toBe(workflow.versionId);
expect(staticData).toEqual(workflow.staticData);
expect(nodes).toEqual(workflow.nodes);
expect(settings).toEqual(workflow.settings);
@ -694,7 +875,7 @@ describe('POST /workflows/:id/activate', () => {
relations: ['workflow'],
});
expect(sharedWorkflow?.workflow.active).toBe(true);
expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId);
// check whether the workflow is on the active workflow runner
expect(await activeWorkflowManager.isActive(workflow.id)).toBe(true);
@ -726,13 +907,24 @@ describe('POST /workflows/:id/deactivate', () => {
`/workflows/${workflow.id}/deactivate`,
);
const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
workflowDeactivationResponse.body;
const {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
name,
createdAt,
updatedAt,
} = workflowDeactivationResponse.body;
expect(id).toEqual(workflow.id);
expect(name).toEqual(workflow.name);
expect(connections).toEqual(workflow.connections);
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(staticData).toEqual(workflow.staticData);
expect(nodes).toEqual(workflow.nodes);
expect(settings).toEqual(workflow.settings);
@ -749,11 +941,42 @@ describe('POST /workflows/:id/deactivate', () => {
});
// check whether the workflow is deactivated in the database
expect(sharedWorkflow?.workflow.active).toBe(false);
expect(sharedWorkflow?.workflow.activeVersionId).toBeNull();
expect(await activeWorkflowManager.isActive(workflow.id)).toBe(false);
});
test('should clear activeVersionId when deactivating workflow', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({}, member);
await authMemberAgent.post(`/workflows/${workflow.id}/activate`);
let sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({
where: {
projectId: memberPersonalProject.id,
workflowId: workflow.id,
},
relations: ['workflow'],
});
expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId);
const deactivateResponse = await authMemberAgent.post(`/workflows/${workflow.id}/deactivate`);
expect(deactivateResponse.statusCode).toBe(200);
expect(deactivateResponse.body.active).toBe(false);
expect(deactivateResponse.body.activeVersionId).toBe(null);
sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({
where: {
projectId: memberPersonalProject.id,
workflowId: workflow.id,
},
relations: ['workflow'],
});
expect(sharedWorkflow?.workflow.activeVersionId).toBeNull();
});
test('should deactivate non-owned workflow when owner', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({}, member);
@ -763,13 +986,24 @@ describe('POST /workflows/:id/deactivate', () => {
`/workflows/${workflow.id}/deactivate`,
);
const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } =
workflowDeactivationResponse.body;
const {
id,
connections,
active,
activeVersionId,
staticData,
nodes,
settings,
name,
createdAt,
updatedAt,
} = workflowDeactivationResponse.body;
expect(id).toEqual(workflow.id);
expect(name).toEqual(workflow.name);
expect(connections).toEqual(workflow.connections);
expect(active).toBe(false);
expect(activeVersionId).toBe(null);
expect(staticData).toEqual(workflow.staticData);
expect(nodes).toEqual(workflow.nodes);
expect(settings).toEqual(workflow.settings);
@ -794,7 +1028,7 @@ describe('POST /workflows/:id/deactivate', () => {
relations: ['workflow'],
});
expect(sharedWorkflow?.workflow.active).toBe(false);
expect(sharedWorkflow?.workflow.activeVersionId).toBeNull();
expect(await activeWorkflowManager.isActive(workflow.id)).toBe(false);
});
@ -842,16 +1076,27 @@ describe('POST /workflows', () => {
expect(response.statusCode).toBe(200);
const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } =
response.body;
const {
id,
name,
nodes,
connections,
staticData,
active,
activeVersionId,
settings,
createdAt,
updatedAt,
} = response.body;
expect(id).toBeDefined();
expect(name).toBe(payload.name);
expect(connections).toEqual(payload.connections);
expect(settings).toEqual(payload.settings);
expect(active).toBe(false);
expect(staticData).toEqual(payload.staticData);
expect(nodes).toEqual(payload.nodes);
expect(active).toBe(false);
expect(activeVersionId).toBe(null);
expect(createdAt).toBeDefined();
expect(updatedAt).toEqual(createdAt);
@ -1042,8 +1287,18 @@ describe('PUT /workflows/:id', () => {
const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload);
const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } =
response.body;
const {
id,
name,
nodes,
connections,
staticData,
active,
activeVersionId,
settings,
createdAt,
updatedAt,
} = response.body;
expect(response.statusCode).toBe(200);
@ -1051,9 +1306,10 @@ describe('PUT /workflows/:id', () => {
expect(name).toBe(payload.name);
expect(connections).toEqual(payload.connections);
expect(settings).toEqual(payload.settings);
expect(active).toBe(false);
expect(staticData).toMatchObject(JSON.parse(payload.staticData));
expect(nodes).toEqual(payload.nodes);
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(createdAt).toBe(workflow.createdAt.toISOString());
expect(updatedAt).not.toBe(workflow.updatedAt.toISOString());
@ -1126,6 +1382,119 @@ describe('PUT /workflows/:id', () => {
expect(historyVersion!.nodes).toEqual(payload.nodes);
});
test('should update activeVersionId when updating an active workflow', async () => {
const workflow = await createActiveWorkflow({}, member);
const updatedPayload = {
name: 'Updated active workflow',
nodes: [
{
id: 'uuid-updated',
parameters: { triggerTimes: { item: [{ mode: 'everyMinute' }] } },
name: 'Updated Cron',
type: 'n8n-nodes-base.cron',
typeVersion: 1,
position: [300, 400],
},
],
connections: {},
staticData: workflow.staticData,
settings: workflow.settings,
};
const updateResponse = await authMemberAgent
.put(`/workflows/${workflow.id}`)
.send(updatedPayload);
expect(updateResponse.statusCode).toBe(200);
await authMemberAgent.post(`/workflows/${workflow.id}/activate`);
const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({
where: {
projectId: memberPersonalProject.id,
workflowId: workflow.id,
},
relations: ['workflow', 'workflow.activeVersion'],
});
expect(sharedWorkflow?.workflow.activeVersionId).toBe(sharedWorkflow?.workflow.versionId);
expect(sharedWorkflow?.workflow.activeVersionId).not.toBe(workflow.activeVersionId);
expect(sharedWorkflow?.workflow.activeVersion?.nodes).toEqual(updatedPayload.nodes);
});
test('should not update activeVersionId when updating an inactive workflow', async () => {
const workflow = await createWorkflow({}, member);
// Update workflow without activating it
const updatedPayload = {
name: 'Updated inactive workflow',
nodes: [
{
id: 'uuid-inactive',
parameters: {},
name: 'Start Node',
type: 'n8n-nodes-base.start',
typeVersion: 1,
position: [200, 300],
},
],
connections: {},
staticData: workflow.staticData,
settings: workflow.settings,
};
const updateResponse = await authMemberAgent
.put(`/workflows/${workflow.id}`)
.send(updatedPayload);
expect(updateResponse.statusCode).toBe(200);
expect(updateResponse.body.active).toBe(false);
expect(updateResponse.body.activeVersionId).toBeNull();
// Verify activeVersion is still null
const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({
where: {
projectId: memberPersonalProject.id,
workflowId: workflow.id,
},
relations: ['workflow'],
});
expect(sharedWorkflow?.workflow.activeVersionId).toBeNull();
});
test('should not allow setting active field via PUT request', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({}, member);
const updatePayload = {
name: 'Try to activate via update',
nodes: workflow.nodes,
connections: workflow.connections,
staticData: workflow.staticData,
settings: workflow.settings,
active: true,
};
const updateResponse = await authMemberAgent
.put(`/workflows/${workflow.id}`)
.send(updatePayload);
expect(updateResponse.statusCode).toBe(400);
expect(updateResponse.body.message).toContain('active');
expect(updateResponse.body.message).toContain('read-only');
const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({
where: {
projectId: memberPersonalProject.id,
workflowId: workflow.id,
},
relations: ['workflow'],
});
expect(sharedWorkflow?.workflow.activeVersionId).toBeNull();
});
test('should update non-owned workflow if owner', async () => {
const workflow = await createWorkflow({}, member);
@ -1165,8 +1534,18 @@ describe('PUT /workflows/:id', () => {
const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload);
const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } =
response.body;
const {
id,
name,
nodes,
connections,
staticData,
active,
activeVersionId,
settings,
createdAt,
updatedAt,
} = response.body;
expect(response.statusCode).toBe(200);
@ -1174,9 +1553,10 @@ describe('PUT /workflows/:id', () => {
expect(name).toBe(payload.name);
expect(connections).toEqual(payload.connections);
expect(settings).toEqual(payload.settings);
expect(active).toBe(false);
expect(staticData).toMatchObject(JSON.parse(payload.staticData));
expect(nodes).toEqual(payload.nodes);
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(createdAt).toBe(workflow.createdAt.toISOString());
expect(updatedAt).not.toBe(workflow.updatedAt.toISOString());

View File

@ -1,4 +1,4 @@
import { testDb } from '@n8n/backend-test-utils';
import { createActiveWorkflow, createWorkflowWithHistory, testDb } from '@n8n/backend-test-utils';
import type { SecurityConfig } from '@n8n/config';
import {
generateNanoId,
@ -46,12 +46,9 @@ test('should report credentials not in any use', async () => {
};
const workflowDetails = {
id: generateNanoId(),
name: 'My Test Workflow',
active: false,
connections: {},
nodeTypes: {},
versionId: uuid(),
nodes: [
{
id: uuid(),
@ -59,13 +56,14 @@ test('should report credentials not in any use', async () => {
type: 'n8n-nodes-base.slack',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
},
],
};
await Promise.all([
Container.get(CredentialsRepository).save(credentialDetails),
Container.get(WorkflowRepository).save(workflowDetails),
createWorkflowWithHistory(workflowDetails),
]);
const testAudit = await securityAuditService.run(['credentials']);
@ -94,12 +92,9 @@ test('should report credentials not in active use', async () => {
const credential = await Container.get(CredentialsRepository).save(credentialDetails);
const workflowDetails = {
id: generateNanoId(),
name: 'My Test Workflow',
active: false,
connections: {},
nodeTypes: {},
versionId: uuid(),
nodes: [
{
id: uuid(),
@ -107,11 +102,12 @@ test('should report credentials not in active use', async () => {
type: 'n8n-nodes-base.slack',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
},
],
};
await Container.get(WorkflowRepository).save(workflowDetails);
await createWorkflowWithHistory(workflowDetails);
const testAudit = await securityAuditService.run(['credentials']);
@ -139,12 +135,9 @@ test('should report credential in not recently executed workflow', async () => {
const credential = await Container.get(CredentialsRepository).save(credentialDetails);
const workflowDetails = {
id: generateNanoId(),
name: 'My Test Workflow',
active: false,
connections: {},
nodeTypes: {},
versionId: uuid(),
nodes: [
{
id: uuid(),
@ -158,11 +151,12 @@ test('should report credential in not recently executed workflow', async () => {
name: credential.name,
},
},
parameters: {},
},
],
};
const workflow = await Container.get(WorkflowRepository).save(workflowDetails);
const workflow = await createWorkflowWithHistory(workflowDetails);
const date = new Date();
date.setDate(date.getDate() - securityConfig.daysAbandonedWorkflow - 1);
@ -209,12 +203,9 @@ test('should not report credentials in recently executed workflow', async () =>
const credential = await Container.get(CredentialsRepository).save(credentialDetails);
const workflowDetails = {
id: generateNanoId(),
name: 'My Test Workflow',
active: true,
connections: {},
nodeTypes: {},
versionId: uuid(),
nodes: [
{
id: uuid(),
@ -228,11 +219,12 @@ test('should not report credentials in recently executed workflow', async () =>
name: credential.name,
},
},
parameters: {},
},
],
};
const workflow = await Container.get(WorkflowRepository).save(workflowDetails);
const workflow = await createActiveWorkflow(workflowDetails);
const date = new Date();
date.setDate(date.getDate() - securityConfig.daysAbandonedWorkflow + 1);

View File

@ -1,6 +1,6 @@
import { testDb } from '@n8n/backend-test-utils';
import { createActiveWorkflow, testDb } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { generateNanoId, WorkflowRepository } from '@n8n/db';
import { WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { NodeConnectionTypes } from 'n8n-workflow';
@ -39,13 +39,9 @@ afterAll(async () => {
test('should report webhook lacking authentication', async () => {
const targetNodeId = uuid();
const details = {
id: generateNanoId(),
await createActiveWorkflow({
name: 'My Test Workflow',
active: true,
nodeTypes: {},
connections: {},
versionId: uuid(),
nodes: [
{
parameters: {
@ -60,9 +56,7 @@ test('should report webhook lacking authentication', async () => {
webhookId: uuid(),
},
],
};
await Container.get(WorkflowRepository).save(details);
});
const testAudit = await securityAuditService.run(['instance']);
@ -83,13 +77,9 @@ test('should report webhook lacking authentication', async () => {
test('should not report webhooks having basic or header auth', async () => {
const promises = ['basicAuth', 'headerAuth'].map(async (authType) => {
const details = {
id: generateNanoId(),
return await createActiveWorkflow({
name: 'My Test Workflow',
active: true,
nodeTypes: {},
connections: {},
versionId: uuid(),
nodes: [
{
parameters: {
@ -105,9 +95,7 @@ test('should not report webhooks having basic or header auth', async () => {
webhookId: uuid(),
},
],
};
return await Container.get(WorkflowRepository).save(details);
});
});
await Promise.all(promises);
@ -129,12 +117,8 @@ test('should not report webhooks having basic or header auth', async () => {
test('should not report webhooks validated by direct children', async () => {
const promises = [...WEBHOOK_VALIDATOR_NODE_TYPES].map(async (nodeType) => {
const details = {
id: generateNanoId(),
return await createActiveWorkflow({
name: 'My Test Workflow',
active: true,
nodeTypes: {},
versionId: uuid(),
nodes: [
{
parameters: {
@ -154,6 +138,7 @@ test('should not report webhooks validated by direct children', async () => {
type: nodeType,
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
},
],
connections: {
@ -169,9 +154,7 @@ test('should not report webhooks validated by direct children', async () => {
],
},
},
};
return await Container.get(WorkflowRepository).save(details);
});
});
await Promise.all(promises);

View File

@ -53,7 +53,7 @@ afterAll(async () => {
});
afterEach(async () => {
await testDb.truncate(['User']);
await testDb.truncate(['User', 'ProjectRelation']);
await cleanupRolesAndScopes();
});

View File

@ -196,6 +196,7 @@ export function makeWorkflow(options?: {
workflow.name = 'My Workflow';
workflow.active = false;
workflow.activeVersionId = null;
workflow.connections = {};
workflow.nodes = [node];

View File

@ -1,7 +1,6 @@
import { createWorkflow, testDb, mockInstance } from '@n8n/backend-test-utils';
import { testDb, mockInstance, createActiveWorkflow } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { readFileSync } from 'fs';
import { mock } from 'jest-mock-extended';
import {
type INode,
type IWorkflowBase,
@ -66,11 +65,15 @@ class WebhookTestingNode implements INodeType {
describe('Webhook API', () => {
const nodeInstance = new WebhookTestingNode();
const node = mock<INode>({
const node: INode = {
id: 'webhook-node-1',
name: 'Webhook',
type: nodeInstance.description.name,
typeVersion: 1,
position: [0, 0],
parameters: {},
webhookId: '5ccef736-be16-4d10-b7fb-feed7a61ff22',
});
};
const workflowData = { active: true, nodes: [node] } as IWorkflowBase;
const nodeTypes = mockInstance(NodeTypes);
@ -91,7 +94,7 @@ describe('Webhook API', () => {
beforeEach(async () => {
await testDb.truncate(['WorkflowEntity']);
await createWorkflow(workflowData, user);
await createActiveWorkflow(workflowData, user);
await initActiveWorkflowManager();
});

View File

@ -1,4 +1,9 @@
import { createWorkflow, testDb, mockInstance } from '@n8n/backend-test-utils';
import {
createWorkflow,
testDb,
mockInstance,
createActiveWorkflow,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { WorkflowHistoryRepository, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
@ -24,7 +29,7 @@ describe('Workflow History Manager', () => {
});
beforeEach(async () => {
await testDb.truncate(['WorkflowEntity']);
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']);
jest.clearAllMocks();
globalConfig.workflowHistory.pruneTime = -1;
@ -92,8 +97,8 @@ describe('Workflow History Manager', () => {
test('should not prune current versions', async () => {
globalConfig.workflowHistory.pruneTime = 24;
const activeWorkflow = await createWorkflow({ active: true });
const inactiveWorkflow = await createWorkflow({ active: false });
const activeWorkflow = await createActiveWorkflow();
const inactiveWorkflow = await createWorkflow();
// Create old history versions for the active workflow
const activeWorkflowVersions = await createManyWorkflowHistoryItems(
@ -130,6 +135,36 @@ describe('Workflow History Manager', () => {
expect(await repo.count({ where: { versionId: In(otherVersionIds) } })).toBe(0);
});
test('should not prune current or active versions when they differ', async () => {
globalConfig.workflowHistory.pruneTime = 24;
const workflow = await createActiveWorkflow();
// Create old history versions
const workflowVersions = await createManyWorkflowHistoryItems(
workflow.id,
5,
DateTime.now().minus({ days: 2 }).toJSDate(),
);
// Set current version to one version and active version to a different version
workflow.versionId = workflowVersions[0].versionId;
workflow.activeVersionId = workflowVersions[1].versionId;
const workflowRepo = Container.get(WorkflowRepository);
await workflowRepo.save(workflow);
await manager.prune();
// Both current and active versions should still exist even though they are old
expect(await repo.count({ where: { versionId: workflow.versionId } })).toBe(1);
expect(await repo.count({ where: { versionId: workflow.activeVersionId } })).toBe(1);
// Other old versions should be deleted
const otherVersionIds = workflowVersions.slice(2).map((i) => i.versionId);
expect(await repo.count({ where: { versionId: In(otherVersionIds) } })).toBe(0);
});
const createWorkflowHistory = async (ageInDays = 2) => {
const workflow = await createWorkflow();
const time = DateTime.now().minus({ days: ageInDays }).toJSDate();
@ -139,7 +174,7 @@ describe('Workflow History Manager', () => {
const pruneAndAssertCount = async (finalCount = 10, initialCount = 10) => {
expect(await repo.count()).toBe(initialCount);
const deleteSpy = jest.spyOn(repo, 'deleteEarlierThanExceptCurrent');
const deleteSpy = jest.spyOn(repo, 'deleteEarlierThanExceptCurrentAndActive');
await manager.prune();
if (initialCount === finalCount) {

View File

@ -65,6 +65,7 @@ describe('WorkflowIndexService Integration', () => {
id: workflowId,
name: 'Test Workflow',
active: false,
activeVersionId: null,
versionCounter: 1,
versionId,
nodes: [

View File

@ -1,4 +1,9 @@
import { createWorkflowWithHistory, testDb, mockInstance } from '@n8n/backend-test-utils';
import {
createWorkflowWithHistory,
createActiveWorkflow,
testDb,
mockInstance,
} from '@n8n/backend-test-utils';
import { SharedWorkflowRepository, type WorkflowEntity, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
@ -44,19 +49,24 @@ beforeAll(async () => {
});
afterEach(async () => {
await testDb.truncate(['WorkflowEntity']);
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']);
jest.restoreAllMocks();
});
describe('update()', () => {
test('should remove and re-add to active workflows on `active: true` payload', async () => {
const owner = await createOwner();
const workflow = await createWorkflowWithHistory({ active: true }, owner);
const workflow = await createActiveWorkflow({}, owner);
const removeSpy = jest.spyOn(activeWorkflowManager, 'remove');
const addSpy = jest.spyOn(activeWorkflowManager, 'add');
await workflowService.update(owner, workflow, workflow.id);
const updateData = {
active: true,
versionId: workflow.versionId,
};
await workflowService.update(owner, updateData as WorkflowEntity, workflow.id);
expect(removeSpy).toHaveBeenCalledTimes(1);
const [removedWorkflowId] = removeSpy.mock.calls[0];
@ -70,13 +80,17 @@ describe('update()', () => {
test('should remove from active workflows on `active: false` payload', async () => {
const owner = await createOwner();
const workflow = await createWorkflowWithHistory({ active: true }, owner);
const workflow = await createActiveWorkflow({}, owner);
const removeSpy = jest.spyOn(activeWorkflowManager, 'remove');
const addSpy = jest.spyOn(activeWorkflowManager, 'add');
workflow.active = false;
await workflowService.update(owner, workflow, workflow.id);
const updateData = {
active: false,
versionId: workflow.versionId,
};
await workflowService.update(owner, updateData as WorkflowEntity, workflow.id);
expect(removeSpy).toHaveBeenCalledTimes(1);
const [removedWorkflowId] = removeSpy.mock.calls[0];
@ -89,7 +103,7 @@ describe('update()', () => {
const owner = await createOwner();
const workflow = await createWorkflowWithHistory({}, owner);
const updateData: Partial<WorkflowEntity> = {
const updateData = {
nodes: [
{
id: 'new-node',
@ -116,11 +130,11 @@ describe('update()', () => {
test('should not save workflow history version when updating only active status', async () => {
const owner = await createOwner();
const workflow = await createWorkflowWithHistory({ active: false }, owner);
const workflow = await createWorkflowWithHistory({}, owner);
const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion');
const updateData: Partial<WorkflowEntity> = {
const updateData = {
active: true,
versionId: workflow.versionId,
};
@ -132,13 +146,12 @@ describe('update()', () => {
test('should save workflow history version with backfilled data when versionId changes', async () => {
const owner = await createOwner();
const workflow = await createWorkflowWithHistory({ active: false }, owner);
const workflow = await createWorkflowWithHistory({}, owner);
const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion');
const newVersionId = 'new-version-id-123';
const updateData: Partial<WorkflowEntity> = {
active: true,
const updateData = {
versionId: newVersionId,
};

View File

@ -1,8 +1,8 @@
import {
createTeamProject,
createWorkflowWithTriggerAndHistory,
testDb,
mockInstance,
createActiveWorkflow,
} from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
@ -39,7 +39,7 @@ describe('PUT /:workflowId/transfer', () => {
//
const destinationProject = await createTeamProject('Team Project', member);
const workflow = await createWorkflowWithTriggerAndHistory({ active: true }, member);
const workflow = await createActiveWorkflow({}, member);
//
// ACT

View File

@ -3,6 +3,7 @@ import {
getPersonalProject,
linkUserToProject,
createWorkflow,
createActiveWorkflow,
createWorkflowWithHistory,
getWorkflowSharing,
shareWorkflowWithProjects,
@ -1421,7 +1422,7 @@ describe('PATCH /workflows/:workflowId', () => {
describe('activate workflow', () => {
test('should activate workflow without changing version ID', async () => {
const workflow = await createWorkflow({}, owner);
const workflow = await createWorkflowWithHistory({}, owner);
const payload = {
versionId: workflow.versionId,
active: true,
@ -1433,16 +1434,17 @@ describe('PATCH /workflows/:workflowId', () => {
expect(activeWorkflowManager.add).toBeCalled();
const {
data: { id, versionId, active },
data: { id, versionId, active, activeVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(versionId).toBe(workflow.versionId);
expect(active).toBe(true);
expect(activeVersionId).toBe(workflow.versionId);
});
test('should deactivate workflow without changing version ID', async () => {
const workflow = await createWorkflowWithHistory({ active: true }, owner);
const workflow = await createActiveWorkflow({}, owner);
const payload = {
versionId: workflow.versionId,
active: false,
@ -1455,12 +1457,13 @@ describe('PATCH /workflows/:workflowId', () => {
expect(activeWorkflowManager.remove).toBeCalled();
const {
data: { id, versionId, active },
data: { id, versionId, active, activeVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(versionId).toBe(workflow.versionId);
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
});
});
});
@ -1636,7 +1639,7 @@ describe('PUT /:workflowId/transfer', () => {
//
const destinationProject = await createTeamProject('Team Project', member);
const workflow = await createWorkflowWithHistory({ active: true }, member);
const workflow = await createActiveWorkflow({}, member);
//
// ACT
@ -1664,10 +1667,7 @@ describe('PUT /:workflowId/transfer', () => {
const folder = await createFolder(destinationProject, { name: 'Test Folder' });
const workflow = await createWorkflowWithHistory(
{ active: true, parentFolder: folder },
member,
);
const workflow = await createActiveWorkflow({ parentFolder: folder }, member);
//
// ACT
@ -1699,10 +1699,7 @@ describe('PUT /:workflowId/transfer', () => {
const folder = await createFolder(destinationProject, { name: 'Test Folder' });
const workflow = await createWorkflowWithHistory(
{ active: true, parentFolder: folder },
member,
);
const workflow = await createActiveWorkflow({ parentFolder: folder }, member);
//
// ACT
@ -1742,10 +1739,7 @@ describe('PUT /:workflowId/transfer', () => {
name: 'Another Test Folder',
});
const workflow = await createWorkflow(
{ active: true, parentFolder: folderInDestinationProject },
member,
);
const workflow = await createWorkflow({ parentFolder: folderInDestinationProject }, member);
//
// ACT
@ -1766,7 +1760,7 @@ describe('PUT /:workflowId/transfer', () => {
//
const destinationProject = await createTeamProject('Team Project', member);
const workflow = await createWorkflowWithHistory({ active: true }, member);
const workflow = await createActiveWorkflow({}, member);
activeWorkflowManager.add.mockRejectedValue(new WorkflowActivationError('Failed'));
@ -1795,7 +1789,8 @@ describe('PUT /:workflowId/transfer', () => {
expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update');
const workflowFromDB = await workflowRepository.findOneByOrFail({ id: workflow.id });
expect(workflowFromDB).toMatchObject({ active: false });
expect(workflowFromDB.active).toBe(false);
expect(workflowFromDB.activeVersionId).toBeNull();
});
test('owner transfers workflow from project they are not part of, e.g. test global cred sharing scope', async () => {
@ -2136,7 +2131,7 @@ describe('PUT /:workflowId/transfer', () => {
//
const destinationProject = await createTeamProject('Team Project', member);
const workflow = await createWorkflowWithHistory({ active: true }, member);
const workflow = await createActiveWorkflow({}, member);
activeWorkflowManager.add.mockRejectedValue(new ApplicationError('Oh no!'));

View File

@ -3,7 +3,10 @@ import {
getPersonalProject,
linkUserToProject,
createWorkflow,
createActiveWorkflow,
setActiveVersion,
createWorkflowWithHistory,
createWorkflowWithTriggerAndHistory,
shareWorkflowWithProjects,
shareWorkflowWithUsers,
randomCredentialPayload,
@ -62,10 +65,10 @@ let folderListMissingRole: Role;
beforeEach(async () => {
await testDb.truncate([
'SharedWorkflow',
'WorkflowHistory',
'ProjectRelation',
'Folder',
'WorkflowEntity',
'WorkflowHistory',
'TagEntity',
'Project',
'User',
@ -131,6 +134,7 @@ describe('POST /workflows', () => {
timezone: 'America/New_York',
},
active: false,
activeVersionId: null,
};
const response = await authMemberAgent.post('/workflows').send(payload);
@ -171,6 +175,7 @@ describe('POST /workflows', () => {
staticData: null,
settings: {},
active: false,
activeVersionId: null,
uiContext: 'workflow_list',
};
@ -210,6 +215,7 @@ describe('POST /workflows', () => {
timezone: 'America/New_York',
},
active: false,
activeVersionId: null,
};
const response = await authOwnerAgent.post('/workflows').send(payload);
@ -234,6 +240,52 @@ describe('POST /workflows', () => {
expect(historyVersion!.nodes).toEqual(payload.nodes);
});
test('should create workflow as active when active: true is provided in POST body', async () => {
const payload = {
name: 'active workflow',
nodes: [
{
id: 'uuid-1234',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
typeVersion: 1,
position: [240, 300],
},
],
connections: {},
staticData: null,
settings: {},
active: true,
};
const response = await authOwnerAgent.post('/workflows').send(payload);
expect(response.statusCode).toBe(200);
const {
data: { id, versionId, activeVersionId, active },
} = response.body;
expect(id).toBeDefined();
expect(versionId).toBeDefined();
expect(activeVersionId).toBe(versionId); // Should be set to current version
expect(active).toBe(true);
// Verify in database
const workflow = await Container.get(WorkflowRepository).findOneBy({ id });
expect(workflow?.activeVersionId).toBe(versionId);
// Verify history was created
const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({
where: {
workflowId: id,
versionId,
},
});
expect(historyVersion).not.toBeNull();
});
test('create workflow in personal project by default', async () => {
//
// ARRANGE
@ -261,6 +313,7 @@ describe('POST /workflows', () => {
});
expect(response.body.data).toMatchObject({
active: false,
activeVersionId: null,
id: expect.any(String),
name: workflow.name,
sharedWithProjects: [],
@ -311,6 +364,7 @@ describe('POST /workflows', () => {
});
expect(response.body.data).toMatchObject({
active: false,
activeVersionId: null,
id: expect.any(String),
name: workflow.name,
sharedWithProjects: [],
@ -407,6 +461,7 @@ describe('POST /workflows', () => {
expect(response.body.data).toMatchObject({
active: false,
activeVersionId: null,
id: expect.any(String),
name: workflow.name,
sharedWithProjects: [],
@ -445,6 +500,7 @@ describe('POST /workflows', () => {
expect(response.body.data).toMatchObject({
active: false,
activeVersionId: null,
id: expect.any(String),
name: workflow.name,
sharedWithProjects: [],
@ -480,6 +536,7 @@ describe('POST /workflows', () => {
expect(response.body.data).toMatchObject({
active: false,
activeVersionId: null,
id: expect.any(String),
name: workflow.name,
sharedWithProjects: [],
@ -613,7 +670,8 @@ describe('GET /workflows', () => {
objectContaining({
id: any(String),
name: 'First',
active: any(Boolean),
active: false,
activeVersionId: null,
createdAt: any(String),
updatedAt: any(String),
tags: [{ id: any(String), name: 'A' }],
@ -629,7 +687,8 @@ describe('GET /workflows', () => {
objectContaining({
id: any(String),
name: 'Second',
active: any(Boolean),
active: false,
activeVersionId: null,
createdAt: any(String),
updatedAt: any(String),
tags: [],
@ -813,8 +872,8 @@ describe('GET /workflows', () => {
});
test('should filter workflows by field: active', async () => {
await createWorkflow({ active: true }, owner);
await createWorkflow({ active: false }, owner);
await createActiveWorkflow({}, owner);
await createWorkflow({}, owner);
const response = await authOwnerAgent
.get('/workflows')
@ -823,7 +882,7 @@ describe('GET /workflows', () => {
expect(response.body).toEqual({
count: 1,
data: [objectContaining({ active: true })],
data: [objectContaining({ active: true, activeVersionId: expect.any(String) })],
});
});
@ -1121,8 +1180,8 @@ describe('GET /workflows', () => {
});
test('should select workflow field: active', async () => {
await createWorkflow({ active: true }, owner);
await createWorkflow({ active: false }, owner);
await createActiveWorkflow({}, owner);
await createWorkflow({}, owner);
const response = await authOwnerAgent
.get('/workflows')
@ -1138,6 +1197,24 @@ describe('GET /workflows', () => {
});
});
test('should select workflow field: activeVersionId', async () => {
const activeWorkflow = await createActiveWorkflow({}, owner);
await createWorkflow({}, owner);
const response = await authOwnerAgent
.get('/workflows')
.query('select=["activeVersionId"]')
.expect(200);
expect(response.body).toEqual({
count: 2,
data: arrayContaining([
{ id: any(String), activeVersionId: activeWorkflow.versionId },
{ id: any(String), activeVersionId: null },
]),
});
});
test('should select workflow field: tags', async () => {
const firstWorkflow = await createWorkflow({ name: 'First' }, owner);
const secondWorkflow = await createWorkflow({ name: 'Second' }, owner);
@ -1477,7 +1554,8 @@ describe('GET /workflows?onlySharedWithMe=true', () => {
objectContaining({
id: any(String),
name: 'Third',
active: any(Boolean),
active: false,
activeVersionId: null,
createdAt: any(String),
updatedAt: any(String),
versionId: any(String),
@ -1549,7 +1627,8 @@ describe('GET /workflows?includeFolders=true', () => {
resource: 'workflow',
id: any(String),
name: 'First',
active: any(Boolean),
active: false,
activeVersionId: null,
createdAt: any(String),
updatedAt: any(String),
tags: [{ id: any(String), name: 'A' }],
@ -1565,7 +1644,7 @@ describe('GET /workflows?includeFolders=true', () => {
objectContaining({
id: any(String),
name: 'Second',
active: any(Boolean),
activeVersionId: null,
createdAt: any(String),
updatedAt: any(String),
tags: [],
@ -1846,8 +1925,8 @@ describe('GET /workflows?includeFolders=true', () => {
});
test('should filter workflows and folders by field: active', async () => {
const workflow1 = await createWorkflow({ active: true }, owner);
await createWorkflow({ active: false }, owner);
const workflow1 = await createActiveWorkflow({}, owner);
await createWorkflow({}, owner);
const response = await authOwnerAgent
.get('/workflows')
@ -1856,7 +1935,9 @@ describe('GET /workflows?includeFolders=true', () => {
expect(response.body).toEqual({
count: 1,
data: [objectContaining({ id: workflow1.id, active: true })],
data: [
objectContaining({ id: workflow1.id, active: true, versionId: workflow1.versionId }),
],
});
});
@ -2408,16 +2489,17 @@ describe('PATCH /workflows/:workflowId', () => {
expect(activeWorkflowManagerLike.add).toBeCalled();
const {
data: { id, versionId, active },
data: { id, versionId, active, activeVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(versionId).toBe(workflow.versionId);
expect(active).toBe(true);
expect(activeVersionId).toBe(workflow.versionId);
});
test('should deactivate workflow without changing version ID', async () => {
const workflow = await createWorkflowWithHistory({ active: true }, owner);
const workflow = await createActiveWorkflow({}, owner);
const payload = {
versionId: workflow.versionId,
active: false,
@ -2430,12 +2512,129 @@ describe('PATCH /workflows/:workflowId', () => {
expect(activeWorkflowManagerLike.remove).toBeCalled();
const {
data: { id, versionId, active },
data: { id, versionId, active, activeVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(versionId).toBe(workflow.versionId);
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
});
test('should set activeVersionId when activating via PATCH', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({}, owner);
const payload = {
versionId: workflow.versionId,
active: true,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalled();
const {
data: { id, activeVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(activeVersionId).toBe(workflow.versionId);
// Verify activeVersion is set
const updatedWorkflow = await Container.get(WorkflowRepository).findOne({
where: { id: workflow.id },
relations: ['activeVersion'],
});
expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId);
expect(updatedWorkflow?.activeVersion).not.toBeNull();
expect(updatedWorkflow?.activeVersion?.versionId).toBe(workflow.versionId);
expect(updatedWorkflow?.activeVersion?.nodes).toEqual(workflow.nodes);
expect(updatedWorkflow?.activeVersion?.connections).toEqual(workflow.connections);
});
test('should clear activeVersionId when deactivating via PATCH', async () => {
const workflow = await createActiveWorkflow({}, owner);
await setActiveVersion(workflow.id, workflow.versionId);
const payload = {
versionId: workflow.versionId,
active: false,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.remove).toBeCalled();
const {
data: { id, activeVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(activeVersionId).toBeNull();
// Verify activeVersion is cleared
const updatedWorkflow = await Container.get(WorkflowRepository).findOne({
where: { id: workflow.id },
});
expect(updatedWorkflow?.activeVersionId).toBeNull();
});
test('should update activeVersionId when updating an active workflow', async () => {
const workflow = await createActiveWorkflow({}, owner);
await setActiveVersion(workflow.id, workflow.versionId);
// Verify initial state
const initialWorkflow = await Container.get(WorkflowRepository).findOne({
where: { id: workflow.id },
relations: ['activeVersion'],
});
expect(initialWorkflow?.activeVersion?.versionId).toBe(workflow.versionId);
// Update workflow nodes
const updatedNodes: INode[] = [
{
id: 'uuid-updated',
parameters: { triggerTimes: { item: [{ mode: 'everyHour' }] } },
name: 'Cron Updated',
type: 'n8n-nodes-base.cron',
typeVersion: 1,
position: [500, 400],
},
];
const payload = {
versionId: workflow.versionId,
nodes: updatedNodes,
connections: {},
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
const {
data: { id, versionId: newVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(newVersionId).not.toBe(workflow.versionId);
// Verify activeVersion points to the new version
const updatedWorkflow = await Container.get(WorkflowRepository).findOne({
where: { id: workflow.id },
relations: ['activeVersion'],
});
expect(updatedWorkflow?.active).toBe(true);
expect(updatedWorkflow?.activeVersionId).not.toBeNull();
expect(updatedWorkflow?.activeVersion?.versionId).toBe(newVersionId);
expect(updatedWorkflow?.activeVersion?.nodes).toEqual(updatedNodes);
});
test('should update workflow meta', async () => {
@ -2559,11 +2758,12 @@ describe('POST /workflows/:workflowId/archive', () => {
.expect(200);
const {
data: { isArchived, versionId },
data: { isArchived, versionId, active },
} = response.body;
expect(isArchived).toBe(true);
expect(versionId).not.toBe(workflow.versionId);
expect(active).toBe(false);
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
expect(updatedWorkflow).not.toBeNull();
@ -2571,17 +2771,18 @@ describe('POST /workflows/:workflowId/archive', () => {
});
test('should deactivate active workflow on archive', async () => {
const workflow = await createWorkflow({ active: true }, owner);
const workflow = await createActiveWorkflow({}, owner);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/archive`)
.send()
.expect(200);
const {
data: { isArchived, versionId, active },
data: { isArchived, versionId, activeVersionId, active },
} = response.body;
expect(isArchived).toBe(true);
expect(activeVersionId).toBeNull();
expect(active).toBe(false);
expect(versionId).not.toBe(workflow.versionId);
expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id);
@ -2824,6 +3025,7 @@ describe('POST /workflows/:workflowId/unarchive', () => {
.expect(200);
expect(activateResponse.body.data.active).toBe(true);
expect(activateResponse.body.data.activeVersionId).toBeDefined();
});
});

View File

@ -20,6 +20,7 @@ export interface WorkflowData {
tags?: string[];
pinData?: IPinData;
versionId?: string;
activeVersionId?: string | null;
meta?: WorkflowMetadata;
}

View File

@ -249,6 +249,7 @@ export interface IWorkflowDb {
homeProject?: ProjectSharingData;
scopes?: Scope[];
versionId: string;
activeVersionId: string | null;
usedCredentials?: IUsedCredential[];
meta?: WorkflowMetadata;
parentFolder?: {
@ -276,6 +277,7 @@ export type WorkflowResource = BaseResource & {
updatedAt: string;
createdAt: string;
active: boolean;
activeVersionId: string | null;
isArchived: boolean;
homeProject?: ProjectSharingData;
scopes?: Scope[];
@ -346,6 +348,7 @@ export interface IWorkflowShortResponse {
id: string;
name: string;
active: boolean;
activeVersionId: string | null;
createdAt: number | string;
updatedAt: number | string;
tags: ITag[];

View File

@ -200,7 +200,8 @@ export function createTestWorkflow({
active,
isArchived,
settings,
versionId: '1',
versionId: 'v1',
activeVersionId: active ? 'v1' : null,
meta: {},
pinData,
...rest,

View File

@ -12,6 +12,9 @@ export const workflowFactory = Factory.extend<IWorkflowDb>({
active() {
return faker.datatype.boolean();
},
activeVersionId(i: number) {
return this.active ? i.toString() : null;
},
isArchived() {
return faker.datatype.boolean();
},

View File

@ -66,6 +66,7 @@ describe('WorkflowDescriptionPopover', () => {
id: 'test-workflow-id',
name: 'Test Workflow',
active: false,
activeVersionId: null,
isArchived: false,
createdAt: Date.now(),
updatedAt: Date.now(),
@ -596,6 +597,7 @@ describe('WorkflowDescriptionPopover', () => {
id: 'test-workflow-id',
name: 'Test Workflow',
active: false,
activeVersionId: null,
isArchived: false,
createdAt: Date.now(),
updatedAt: Date.now(),
@ -642,6 +644,7 @@ describe('WorkflowDescriptionPopover', () => {
id: 'test-workflow-id',
name: 'Test Workflow',
active: false,
activeVersionId: null,
isArchived: false,
createdAt: Date.now(),
updatedAt: Date.now(),

View File

@ -61,6 +61,7 @@ const createWorkflow = (overrides = {}): WorkflowResource => ({
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
active: true,
activeVersionId: 'v1',
isArchived: false,
readOnly: false,
...overrides,

View File

@ -64,6 +64,7 @@ const mockWorkflow: IWorkflowDb = {
id: 'test-workflow-id',
name: 'Test Workflow',
active: true,
activeVersionId: 'v1',
nodes: [],
settings: {
executionOrder: 'v1',
@ -173,6 +174,7 @@ describe('WorkflowProductionChecklist', () => {
workflow: {
...mockWorkflow,
active: false,
activeVersionId: null,
},
},
pinia: createTestingPinia(),
@ -537,6 +539,7 @@ describe('WorkflowProductionChecklist', () => {
workflow: {
...mockWorkflow,
active: false,
activeVersionId: null,
},
},
pinia: createTestingPinia(),
@ -545,7 +548,6 @@ describe('WorkflowProductionChecklist', () => {
await rerender({
workflow: {
...mockWorkflow,
active: true,
},
});
@ -570,6 +572,7 @@ describe('WorkflowProductionChecklist', () => {
workflow: {
...mockWorkflow,
active: false,
activeVersionId: null,
},
},
pinia: createTestingPinia(),
@ -578,7 +581,6 @@ describe('WorkflowProductionChecklist', () => {
await rerender({
workflow: {
...mockWorkflow,
active: true,
},
});
@ -608,6 +610,7 @@ describe('WorkflowProductionChecklist', () => {
workflow: {
...mockWorkflow,
active: false,
activeVersionId: null,
},
},
pinia,
@ -616,7 +619,6 @@ describe('WorkflowProductionChecklist', () => {
await rerender({
workflow: {
...mockWorkflow,
active: true,
},
});

View File

@ -85,7 +85,7 @@ const isMcpAvailable = computed(() => {
});
const availableActions = computed(() => {
if (!props.workflow.active || workflowsCache.isCacheLoading.value) {
if (props.workflow.activeVersionId === null || workflowsCache.isCacheLoading.value) {
return [];
}
@ -228,7 +228,7 @@ function handlePopoverOpenChange(open: boolean) {
// Watch for workflow activation
watch(
() => props.workflow.active,
() => !!props.workflow.activeVersionId,
async (isActive, wasActive) => {
if (isActive && !wasActive) {
// Check if this is the first activation

View File

@ -60,6 +60,7 @@ describe('WorkflowSettingsVue', () => {
id: '1',
name: 'Test Workflow',
active: true,
activeVersionId: 'v1',
isArchived: false,
nodes: [],
connections: {},
@ -72,6 +73,7 @@ describe('WorkflowSettingsVue', () => {
id: '1',
name: 'Test Workflow',
active: true,
activeVersionId: 'v1',
isArchived: false,
nodes: [],
connections: {},
@ -279,6 +281,7 @@ describe('WorkflowSettingsVue', () => {
id: '1',
name: 'Test Workflow',
active: true,
activeVersionId: 'v1',
isArchived: false,
nodes: [],
connections: {},

Some files were not shown because too many files have changed in this diff Show More