mirror of
https://github.com/n8n-io/n8n.git
synced 2025-11-20 17:46:34 +00:00
feat(core): Use active version instead of current version (no-changelog) (#21202)
This commit is contained in:
parent
c7348970b3
commit
ac91020bd3
@ -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;
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -65,6 +65,7 @@ describe('ActiveExecutions', () => {
|
||||
id: '123',
|
||||
name: 'Test workflow 1',
|
||||
active: false,
|
||||
activeVersionId: null,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1215,6 +1215,7 @@ describe('TelemetryEventRelay', () => {
|
||||
id: 'workflow123',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
activeVersionId: 'some-version-id',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
|
||||
@ -62,6 +62,7 @@ describe('Execution Lifecycle Hooks', () => {
|
||||
id: workflowId,
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
activeVersionId: 'some-version-id',
|
||||
isArchived: false,
|
||||
connections: {},
|
||||
nodes: [
|
||||
|
||||
@ -38,6 +38,7 @@ export function prepareExecutionDataForDbUpdate(parameters: {
|
||||
'id',
|
||||
'name',
|
||||
'active',
|
||||
'activeVersionId',
|
||||
'isArchived',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
|
||||
@ -183,6 +183,7 @@ export class ExecutionService {
|
||||
const executionMode = 'retry';
|
||||
|
||||
execution.workflowData.active = false;
|
||||
execution.workflowData.activeVersionId = null;
|
||||
|
||||
// Start the workflow
|
||||
const data: IWorkflowExecutionDataProcess = {
|
||||
|
||||
15
packages/cli/src/executions/execution.utils.ts
Normal file
15
packages/cli/src/executions/execution.utils.ts
Normal 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;
|
||||
}
|
||||
@ -6,6 +6,7 @@ export class WorkflowSelect extends BaseSelect {
|
||||
'id', // always included downstream
|
||||
'name',
|
||||
'active',
|
||||
'activeVersionId',
|
||||
'tags',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 }>;
|
||||
|
||||
@ -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
|
||||
@ -50,3 +50,5 @@ properties:
|
||||
type: array
|
||||
items:
|
||||
$ref: './sharedWorkflow.yml'
|
||||
activeVersion:
|
||||
$ref: './activeVersion.yml'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.`);
|
||||
}
|
||||
|
||||
135
packages/cli/src/webhooks/__tests__/live-webhooks.test.ts
Normal file
135
packages/cli/src/webhooks/__tests__/live-webhooks.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -127,7 +127,7 @@ describe('Cross-Project Access Control Tests', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.truncate(['User']);
|
||||
await testDb.truncate(['User', 'ProjectRelation']);
|
||||
await cleanupRolesAndScopes();
|
||||
});
|
||||
|
||||
|
||||
@ -166,7 +166,7 @@ describe('Custom Role Functionality Tests', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.truncate(['User']);
|
||||
await testDb.truncate(['User', 'ProjectRelation']);
|
||||
await cleanupRolesAndScopes();
|
||||
});
|
||||
|
||||
|
||||
@ -135,7 +135,7 @@ describe('Resource Access Control Matrix Tests', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.truncate(['User']);
|
||||
await testDb.truncate(['User', 'ProjectRelation']);
|
||||
await cleanupRolesAndScopes();
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 },
|
||||
]);
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(),
|
||||
]);
|
||||
|
||||
//
|
||||
|
||||
@ -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!'));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -53,7 +53,7 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await testDb.truncate(['User']);
|
||||
await testDb.truncate(['User', 'ProjectRelation']);
|
||||
await cleanupRolesAndScopes();
|
||||
});
|
||||
|
||||
|
||||
@ -196,6 +196,7 @@ export function makeWorkflow(options?: {
|
||||
|
||||
workflow.name = 'My Workflow';
|
||||
workflow.active = false;
|
||||
workflow.activeVersionId = null;
|
||||
workflow.connections = {};
|
||||
workflow.nodes = [node];
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -65,6 +65,7 @@ describe('WorkflowIndexService Integration', () => {
|
||||
id: workflowId,
|
||||
name: 'Test Workflow',
|
||||
active: false,
|
||||
activeVersionId: null,
|
||||
versionCounter: 1,
|
||||
versionId,
|
||||
nodes: [
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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!'));
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ export interface WorkflowData {
|
||||
tags?: string[];
|
||||
pinData?: IPinData;
|
||||
versionId?: string;
|
||||
activeVersionId?: string | null;
|
||||
meta?: WorkflowMetadata;
|
||||
}
|
||||
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -200,7 +200,8 @@ export function createTestWorkflow({
|
||||
active,
|
||||
isArchived,
|
||||
settings,
|
||||
versionId: '1',
|
||||
versionId: 'v1',
|
||||
activeVersionId: active ? 'v1' : null,
|
||||
meta: {},
|
||||
pinData,
|
||||
...rest,
|
||||
|
||||
@ -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();
|
||||
},
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user